typescript
import { z } from "zod";
export type WeightedSymbol = {
symbol: string;
weight: number;
};
export type SymbolInput = WeightedSymbol | string;
export const DEFAULT_SYMBOLS: WeightedSymbol[] = [
{ symbol: "MSFT", weight: 0.2 },
{ symbol: "AAPL", weight: 0.2 },
{ symbol: "NVDA", weight: 0.2 },
{ symbol: "AMZN", weight: 0.15 },
{ symbol: "GOOGL", weight: 0.15 },
{ symbol: "META", weight: 0.1 },
];
const CONFIG_ENV = "OPENTOOL_PUBLIC_HL_TECH_DCA_CONFIG";
const numericString = z.union([z.string(), z.number()]).transform((value) => String(value));
export const weightedSymbolSchema = z.object({
symbol: z.string().min(1),
weight: z.number().positive(),
});
const symbolInputSchema = z.union([weightedSymbolSchema, z.string().min(1)]);
export const configSchema = z.object({
environment: z.enum(["mainnet", "testnet"]).optional(),
schedule: z.string().optional(),
symbols: z.array(symbolInputSchema).min(1).optional(),
allocationMode: z.enum(["percent", "fixed"]).optional(),
percentOfEquity: numericString.optional(),
maxPercentOfEquity: numericString.optional(),
maxPerRunUsd: numericString.optional(),
amountUsd: numericString.optional(),
slippageBps: z.number().int().min(0).max(1000).optional(),
momentumTilt: z.boolean().optional(),
rebalance: z.boolean().optional(),
});
export type TechDcaConfig = z.infer<typeof configSchema>;
function parseJson(raw: string | null) {
if (!raw) return null;
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
export function readConfig(): TechDcaConfig {
const envConfig = parseJson(process.env[CONFIG_ENV] ?? null);
const parsed = configSchema.safeParse(envConfig);
return parsed.success ? parsed.data : {};
}
export function resolveChainConfig(environment: "mainnet" | "testnet") {
return environment === "mainnet"
? { chain: "arbitrum", rpcUrl: process.env.ARBITRUM_RPC_URL }
: { chain: "arbitrum-sepolia", rpcUrl: process.env.ARBITRUM_SEPOLIA_RPC_URL };
}
export function normalizeTechSymbol(symbol: string): string {
const cleaned = symbol.trim().toUpperCase();
if (!cleaned) return cleaned;
if (cleaned.includes(":") || cleaned.includes("-")) return cleaned;
return `xyz:${cleaned}`;
}
export function normalizeWeights(entries: WeightedSymbol[]): WeightedSymbol[] {
const cleaned = entries
.map((entry) => ({
symbol: entry.symbol.trim(),
weight: entry.weight,
}))
.filter((entry) => entry.symbol.length > 0 && Number.isFinite(entry.weight));
if (cleaned.length === 0) return [];
const total = cleaned.reduce((acc, entry) => acc + entry.weight, 0);
if (!Number.isFinite(total) || total <= 0) {
const weight = Number((1 / cleaned.length).toFixed(6));
return cleaned.map((entry) => ({ ...entry, weight }));
}
return cleaned.map((entry) => ({
...entry,
weight: entry.weight / total,
}));
}
export function normalizeSymbolInputs(entries: SymbolInput[]): WeightedSymbol[] {
const normalized = entries
.map((entry) => {
if (typeof entry === "string") {
return { symbol: entry.trim(), weight: 1 };
}
return {
symbol: entry.symbol.trim(),
weight: entry.weight,
};
})
.filter((entry) => entry.symbol.length > 0 && Number.isFinite(entry.weight));
return normalizeWeights(normalized);
}
export function normalizeWeightValues(weights: number[]): number[] {
if (weights.length === 0) return [];
const total = weights.reduce((acc, value) => acc + value, 0);
if (!Number.isFinite(total) || total <= 0) {
const fallback = Number((1 / weights.length).toFixed(6));
return weights.map(() => fallback);
}
return weights.map((value) => value / total);
}
function countDecimals(value: number): number {
if (!Number.isFinite(value)) return 0;
const s = value.toString();
const [, dec = ""] = s.split(".");
return dec.length;
}
export function formatMarketablePrice(
mid: number,
side: "buy" | "sell",
slippageBps: number
): string {
const decimals = countDecimals(mid);
const factor = 10 ** decimals;
const adjusted =
mid * (side === "buy" ? 1 + slippageBps / 10_000 : 1 - slippageBps / 10_000);
const scaled = adjusted * factor;
const rounded = side === "buy" ? Math.ceil(scaled) / factor : Math.floor(scaled) / factor;
return rounded.toString();
}
type MarketListResponse = {
markets?: Array<{
symbol?: string;
price?: number | null;
}>;
};
const MARKET_LIST_TTL_MS = 10_000;
let cachedMarketList: { fetchedAt: number; prices: Map<string, number> } | null = null;
async function fetchMarketListPrices(): Promise<Map<string, number>> {
if (cachedMarketList && Date.now() - cachedMarketList.fetchedAt < MARKET_LIST_TTL_MS) {
return cachedMarketList.prices;
}
const gatewayBase = process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, "");
if (!gatewayBase) {
throw new Error("OPENPOND_GATEWAY_URL is not configured.");
}
const url = `${gatewayBase}/v1/hyperliquid/market-list`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch market list (${res.status}).`);
}
const data = (await res.json().catch(() => null)) as MarketListResponse | null;
const markets = Array.isArray(data?.markets) ? data?.markets : [];
const prices = new Map<string, number>();
for (const market of markets) {
const symbol = typeof market.symbol === "string" ? market.symbol.trim() : "";
const price = typeof market.price === "number" ? market.price : null;
if (!symbol || price == null || price <= 0) continue;
prices.set(symbol.toLowerCase(), price);
}
cachedMarketList = { fetchedAt: Date.now(), prices };
return prices;
}
export async function fetchMarkPrice(symbol: string) {
const prices = await fetchMarketListPrices();
const key = symbol.trim().toLowerCase();
const price = prices.get(key);
if (price == null || price <= 0) {
throw new Error(`Gateway did not return a price for ${symbol}.`);
}
return price;
}
type PerpMetaResponse = {
universe?: Array<{
name?: string;
szDecimals?: number;
isDelisted?: boolean;
}>;
};
const META_CACHE_TTL_MS = 5 * 60 * 1000;
const metaCache = new Map<
string,
{
fetchedAt: number;
universe: Array<{ name: string; szDecimals: number; isDelisted: boolean }>;
}
>();
function resolveHyperliquidApiBase(environment: "mainnet" | "testnet") {
return environment === "mainnet"
? "https://api.hyperliquid.xyz"
: "https://api.hyperliquid-testnet.xyz";
}
function extractDex(symbol: string): string | null {
const idx = symbol.indexOf(":");
if (idx <= 0) return null;
const dex = symbol.slice(0, idx).trim().toLowerCase();
return dex || null;
}
async function fetchPerpUniverse(
environment: "mainnet" | "testnet",
dex?: string | null
) {
const dexKey = dex ? dex.trim().toLowerCase() : "";
const cacheKey = `${environment}:${dexKey}`;
const cached = metaCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < META_CACHE_TTL_MS) {
return cached.universe;
}
const baseUrl = resolveHyperliquidApiBase(environment);
const payload = dexKey ? { type: "meta", dex: dexKey } : { type: "meta" };
const res = await fetch(`${baseUrl}/info`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const json = (await res.json().catch(() => null)) as PerpMetaResponse | null;
const universe = Array.isArray(json?.universe) ? json?.universe : null;
if (!res.ok || !universe) {
throw new Error(`Failed to load Hyperliquid perp metadata (${res.status}).`);
}
const normalized = universe
.map((entry) => ({
name: typeof entry.name === "string" ? entry.name : "",
szDecimals: typeof entry.szDecimals === "number" ? entry.szDecimals : NaN,
isDelisted: Boolean(entry.isDelisted),
}))
.filter(
(entry) =>
entry.name.length > 0 && Number.isFinite(entry.szDecimals)
) as Array<{ name: string; szDecimals: number; isDelisted: boolean }>;
metaCache.set(cacheKey, { fetchedAt: Date.now(), universe: normalized });
return normalized;
}
export async function fetchSymbolMeta(
symbol: string,
environment: "mainnet" | "testnet"
): Promise<{ szDecimals: number; isDelisted: boolean }> {
const dex = extractDex(symbol);
const universe = await fetchPerpUniverse(environment, dex);
const match = universe.find(
(entry) => entry.name.toUpperCase() === symbol.toUpperCase()
);
if (!match) {
throw new Error(`No size decimals found for ${symbol}.`);
}
return { szDecimals: match.szDecimals, isDelisted: match.isDelisted };
}
export async function fetchSizeDecimals(
symbol: string,
environment: "mainnet" | "testnet"
) {
const meta = await fetchSymbolMeta(symbol, environment);
return meta.szDecimals;
}
type CandleSnapshot = {
t?: number;
time?: number;
c?: string;
close?: string;
};
export const MOMENTUM_LOOKBACK_DAYS = 7;
export const MOMENTUM_TILT_STRENGTH = 0.5;
function normalizeCandleTime(rawTime: number): number {
return rawTime > 1_000_000_000_000 ? rawTime : rawTime * 1000;
}
export async function fetchMomentumReturn(
symbol: string,
environment: "mainnet" | "testnet",
lookbackDays: number
): Promise<number> {
const baseUrl = resolveHyperliquidApiBase(environment);
const endTime = Date.now();
const startTime = endTime - lookbackDays * 24 * 60 * 60 * 1000;
const res = await fetch(`${baseUrl}/info`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "candleSnapshot",
req: {
coin: symbol,
interval: "1d",
startTime,
endTime,
},
}),
});
const data = (await res.json().catch(() => null)) as CandleSnapshot[] | null;
if (!res.ok || !Array.isArray(data)) {
throw new Error(`Failed to load momentum candles for ${symbol} (${res.status}).`);
}
const candles = data
.map((candle) => {
const rawTime = candle.t ?? candle.time ?? 0;
const close = Number.parseFloat(candle.c ?? candle.close ?? "0");
return {
time: normalizeCandleTime(rawTime),
close,
};
})
.filter((candle) => Number.isFinite(candle.close) && candle.close > 0)
.sort((a, b) => a.time - b.time);
if (candles.length < 2) return 0;
const start = candles[0]?.close ?? 0;
const end = candles[candles.length - 1]?.close ?? 0;
if (!Number.isFinite(start) || start <= 0 || !Number.isFinite(end)) return 0;
return (end - start) / start;
}
export function applyMomentumTilt(
baseWeights: number[],
returns: number[],
strength: number
): number[] {
if (baseWeights.length === 0 || baseWeights.length !== returns.length) {
return baseWeights;
}
const min = Math.min(...returns);
const max = Math.max(...returns);
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
return baseWeights;
}
const scaled = returns.map((value) => (value - min) / (max - min));
const multipliers = scaled.map((value) => 1 + strength * value);
const tilted = baseWeights.map((weight, index) => weight * multipliers[index]);
return normalizeWeightValues(tilted);
}
export function formatOrderSize(value: number, szDecimals: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
const factor = 10 ** szDecimals;
const rounded = Math.floor(value * factor) / factor;
const fixed = rounded.toFixed(szDecimals);
return fixed.replace(/\.?0+$/, "");
}