typescript
import { store } from "opentool/store";
import { wallet } from "opentool/wallet";
import type { ToolProfile } from "opentool";
import {
fetchHyperliquidClearinghouseState,
placeHyperliquidOrder,
type HyperliquidOrderResponse,
} from "opentool/adapters/hyperliquid";
import type { WalletFullContext } from "opentool/wallet";
import {
DEFAULT_SYMBOLS,
MOMENTUM_LOOKBACK_DAYS,
MOMENTUM_TILT_STRENGTH,
applyMomentumTilt,
fetchMarkPrice,
fetchMomentumReturn,
fetchSizeDecimals,
formatMarketablePrice,
formatOrderSize,
normalizeCryptoSymbol,
normalizeSymbolInputs,
readConfig,
resolveChainConfig,
} from "../src/crypto-movers";
const DEFAULT_ALLOCATION_MODE: "percent" | "fixed" = "percent";
const DEFAULT_PERCENT_OF_EQUITY = 2;
const DEFAULT_MAX_PERCENT_OF_EQUITY = 10;
const DEFAULT_MAX_PER_RUN_USD = 500;
const DEFAULT_AMOUNT_USD = 400;
const DEFAULT_ENVIRONMENT: "mainnet" | "testnet" = "mainnet";
const DEFAULT_SLIPPAGE_BPS = 50;
const STRATEGY_ID = "crypto-movers";
const CONFIG_ENV_NAME = "OPENTOOL_PUBLIC_HL_CRYPTO_MOVERS_CONFIG";
export const profile: ToolProfile = {
description: "Weekly DCA into top crypto movers on Hyperliquid.",
category: "strategy",
schedule: { cron: "0 12 * * 1", enabled: false },
chains: ["hyperliquid"],
};
function toNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function resolveNumber(value: unknown, fallback: number): number {
const parsed = toNumber(value);
return parsed === null ? fallback : parsed;
}
function resolveBudget(params: {
allocationMode: "percent" | "fixed";
percentOfEquity: number;
maxPercentOfEquity: number;
maxPerRunUsd: number;
amountUsd: number;
accountValue: number;
}) {
const percent = Number.isFinite(params.percentOfEquity)
? Math.max(params.percentOfEquity, 0)
: 0;
const maxPercent = Number.isFinite(params.maxPercentOfEquity)
? Math.max(params.maxPercentOfEquity, 0)
: 0;
const maxPerRun = Number.isFinite(params.maxPerRunUsd)
? Math.max(params.maxPerRunUsd, 0)
: 0;
const amountUsd = Number.isFinite(params.amountUsd)
? Math.max(params.amountUsd, 0)
: 0;
const accountValue = Number.isFinite(params.accountValue)
? Math.max(params.accountValue, 0)
: 0;
if (params.allocationMode === "fixed") {
return maxPerRun > 0 ? Math.min(amountUsd, maxPerRun) : amountUsd;
}
let budget = accountValue > 0 && percent > 0 ? (accountValue * percent) / 100 : 0;
if (accountValue > 0 && maxPercent > 0) {
budget = Math.min(budget, (accountValue * maxPercent) / 100);
}
if (maxPerRun > 0) {
budget = Math.min(budget, maxPerRun);
}
return budget;
}
function resolvePositionSize(assetPositions: any[], symbol: string): number {
const normalizedSymbol = symbol.toUpperCase();
const base = normalizedSymbol.split("-")[0];
for (const pos of assetPositions) {
const coin =
typeof pos.coin === "string" ? pos.coin : typeof pos?.position?.coin === "string" ? pos.position.coin : "";
if (!coin) continue;
const coinNorm = coin.toUpperCase();
if (coinNorm === normalizedSymbol || coinNorm === base || coinNorm.startsWith(base)) {
const rawSize = pos.szi ?? pos.position?.szi ?? 0;
const parsed = Number.parseFloat(String(rawSize));
if (Number.isFinite(parsed)) return parsed;
}
}
return 0;
}
function extractOrderIds(result: HyperliquidOrderResponse | unknown): {
cloids: string[];
oids: string[];
} {
const cloids = new Set<string>();
const oids = new Set<string>();
const push = (value: unknown, target: Set<string>) => {
if (value === null || value === undefined) return;
const str = String(value);
if (str.length) target.add(str);
};
const statuses = (result as any)?.response?.data?.statuses as
| Array<{
resting?: { cloid?: string | number; oid?: string | number };
filled?: { cloid?: string | number; oid?: string | number };
}>
| undefined;
if (!Array.isArray(statuses)) {
return { cloids: [], oids: [] };
}
for (const status of statuses) {
push(status.resting?.cloid, cloids);
push(status.resting?.oid, oids);
push(status.filled?.cloid, cloids);
push(status.filled?.oid, oids);
}
return {
cloids: Array.from(cloids),
oids: Array.from(oids),
};
}
export async function GET(_req: Request) {
const configRaw = process.env[CONFIG_ENV_NAME] ?? null;
const config = readConfig();
try {
const environment = config.environment ?? DEFAULT_ENVIRONMENT;
const allocationMode = config.allocationMode ?? DEFAULT_ALLOCATION_MODE;
const percentOfEquity = resolveNumber(
config.percentOfEquity,
DEFAULT_PERCENT_OF_EQUITY
);
const maxPercentOfEquity = resolveNumber(
config.maxPercentOfEquity,
DEFAULT_MAX_PERCENT_OF_EQUITY
);
const maxPerRunUsd = resolveNumber(config.maxPerRunUsd, DEFAULT_MAX_PER_RUN_USD);
const amountUsd = resolveNumber(config.amountUsd, DEFAULT_AMOUNT_USD);
const slippageBps = config.slippageBps ?? DEFAULT_SLIPPAGE_BPS;
const momentumTilt = config.momentumTilt ?? config.rebalance ?? false;
const schedule = config.schedule ?? "weekly";
const chainConfig = resolveChainConfig(environment);
const ctx = await wallet({ chain: chainConfig.chain });
const rawSymbols = (config.symbols?.length ? config.symbols : DEFAULT_SYMBOLS) as Parameters<
typeof normalizeSymbolInputs
>[0];
const normalized = normalizeSymbolInputs(rawSymbols).map((entry) => ({
...entry,
normalizedSymbol: normalizeCryptoSymbol(entry.symbol),
}));
if (normalized.length === 0) {
throw new Error("No symbols configured.");
}
const prices = await Promise.all(
normalized.map((entry) => fetchMarkPrice(entry.normalizedSymbol))
);
const priceBySymbol = new Map<string, number>();
normalized.forEach((entry, index) => {
priceBySymbol.set(entry.normalizedSymbol, prices[index]);
});
const sizeDecimals = await Promise.all(
normalized.map((entry) => fetchSizeDecimals(entry.normalizedSymbol, environment))
);
const sizeDecimalsBySymbol = new Map<string, number>();
normalized.forEach((entry, index) => {
sizeDecimalsBySymbol.set(entry.normalizedSymbol, sizeDecimals[index] ?? 0);
});
const baseWeights = normalized.map((entry) => entry.weight);
let momentumReturns: number[] | null = null;
let targetWeights = baseWeights;
if (momentumTilt) {
momentumReturns = await Promise.all(
normalized.map((entry) =>
fetchMomentumReturn(
entry.normalizedSymbol,
environment,
MOMENTUM_LOOKBACK_DAYS
)
)
);
targetWeights = applyMomentumTilt(
baseWeights,
momentumReturns,
MOMENTUM_TILT_STRENGTH
);
}
let totalCurrent = 0;
const currentNotional = new Map<string, number>();
const clearing = await fetchHyperliquidClearinghouseState({
environment,
walletAddress: ctx.address as `0x${string}`,
});
const clearingData = (clearing as any)?.data ?? clearing;
const assetPositions = (clearingData as any)?.assetPositions;
if (!Array.isArray(assetPositions)) {
throw new Error("Hyperliquid clearinghouseState did not return assetPositions.");
}
const marginSummary =
(clearingData as any)?.marginSummary ??
(clearingData as any)?.crossMarginSummary ??
null;
const accountValue =
toNumber(marginSummary?.accountValue) ??
toNumber((clearingData as any)?.crossMarginSummary?.accountValue) ??
0;
const withdrawable =
toNumber((clearingData as any)?.withdrawable) ??
toNumber(marginSummary?.withdrawable) ??
toNumber((clearingData as any)?.crossMarginSummary?.withdrawable) ??
0;
const budgetUsd = resolveBudget({
allocationMode,
percentOfEquity,
maxPercentOfEquity,
maxPerRunUsd,
amountUsd,
accountValue,
});
const canSpend = budgetUsd > 0 && withdrawable >= budgetUsd;
if (!canSpend) {
await store({
source: "hyperliquid",
ref: `${STRATEGY_ID}-${Date.now()}`,
status: "info",
walletAddress: ctx.address,
action: "noop",
notional: budgetUsd.toString(),
network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet",
metadata: {
strategyId: STRATEGY_ID,
environment,
schedule,
momentumTilt,
momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS,
momentumTiltStrength: MOMENTUM_TILT_STRENGTH,
allocationMode,
percentOfEquity,
maxPercentOfEquity,
maxPerRunUsd,
amountUsd,
accountValue,
withdrawable,
budgetUsd,
slippageBps,
skipReason: "insufficient_funds",
},
});
return Response.json({
ok: true,
skipped: true,
reason: "insufficient_funds",
strategyId: STRATEGY_ID,
environment,
schedule,
allocationMode,
percentOfEquity,
maxPercentOfEquity,
maxPerRunUsd,
amountUsd,
accountValue,
withdrawable,
budgetUsd,
});
}
for (const entry of normalized) {
const price = priceBySymbol.get(entry.normalizedSymbol) ?? 0;
const size = resolvePositionSize(assetPositions, entry.normalizedSymbol);
const notional = size > 0 && Number.isFinite(price) ? size * price : 0;
currentNotional.set(entry.normalizedSymbol, notional);
totalCurrent += notional;
}
let allocationWeights = targetWeights;
if (totalCurrent > 0) {
const deficits = normalized.map((entry, index) => {
const target = totalCurrent * targetWeights[index];
const current = currentNotional.get(entry.normalizedSymbol) ?? 0;
return Math.max(target - current, 0);
});
const deficitSum = deficits.reduce((acc, value) => acc + value, 0);
if (deficitSum > 0) {
allocationWeights = deficits.map((value) => value / deficitSum);
}
}
const orders: Array<Parameters<typeof placeHyperliquidOrder>[0]["orders"][number]> = [];
const allocations = normalized.map((entry, index) => {
const price = priceBySymbol.get(entry.normalizedSymbol) ?? 0;
const usdAllocation = budgetUsd * allocationWeights[index];
const size = price > 0 ? usdAllocation / price : 0;
const szDecimals = sizeDecimalsBySymbol.get(entry.normalizedSymbol);
if (szDecimals === undefined) {
throw new Error(`Missing size decimals for ${entry.normalizedSymbol}.`);
}
const sizeStr = formatOrderSize(size, szDecimals);
const sizeValue = Number.parseFloat(sizeStr) || 0;
if (sizeValue > 0) {
orders.push({
symbol: entry.normalizedSymbol,
side: "buy",
price: formatMarketablePrice(price, "buy", slippageBps),
size: sizeStr,
tif: "FrontendMarket",
reduceOnly: false,
});
}
return {
symbol: entry.symbol,
normalizedSymbol: entry.normalizedSymbol,
baseWeight: baseWeights[index],
targetWeight: targetWeights[index],
allocationWeight: allocationWeights[index],
momentumReturn: momentumReturns ? momentumReturns[index] ?? 0 : null,
usdAllocation,
price,
size: sizeValue,
};
});
let orderResponse: HyperliquidOrderResponse | null = null;
if (orders.length) {
orderResponse = (await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders,
})) as HyperliquidOrderResponse;
}
const orderIds = orderResponse ? extractOrderIds(orderResponse) : { cloids: [], oids: [] };
const ref =
orderIds.cloids[0] ??
orderIds.oids[0] ??
`${STRATEGY_ID}-${Date.now()}`;
await store({
source: "hyperliquid",
ref,
status: orders.length ? "submitted" : "info",
walletAddress: ctx.address,
action: orders.length ? "dca" : "noop",
notional: budgetUsd.toString(),
network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet",
metadata: {
strategyId: STRATEGY_ID,
environment,
schedule,
momentumTilt,
momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS,
momentumTiltStrength: MOMENTUM_TILT_STRENGTH,
allocationMode,
percentOfEquity,
maxPercentOfEquity,
maxPerRunUsd,
amountUsd,
accountValue,
withdrawable,
budgetUsd,
slippageBps,
allocations,
orderIds,
orderResponse: orderResponse ?? undefined,
},
});
return Response.json({
ok: true,
strategyId: STRATEGY_ID,
environment,
schedule,
momentumTilt,
momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS,
allocationMode,
percentOfEquity,
maxPercentOfEquity,
maxPerRunUsd,
amountUsd,
accountValue,
withdrawable,
budgetUsd,
allocations,
orders: orders.length,
orderIds,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const debugConfig = configRaw ?? "null";
console.error("hyperliquid-crypto-movers.error", {
message,
configRaw,
configParsed: config,
});
return Response.json(
{
ok: false,
error: `${message} | ${CONFIG_ENV_NAME}=${debugConfig}`,
configRaw,
configParsed: config,
},
{ status: 500 }
);
}
}