openpondai/agents/my-openpond
OpenTool app
typescript
import { normalizeHyperliquidBaseSymbol } from "opentool/adapters/hyperliquid";
import { z } from "zod";
import { readConfig, resolveTradeReviewConfig } from "./config";
import { fetchGatewayHyperliquidCloses } from "./hyperliquid-bars";
import { computeMacd } from "./indicators/computeMacd";
import { computeRsi } from "./indicators/computeRsi";
import { reviewTrade, type ReviewTradeResult } from "./review-trade";
const generateTradeOpenPositionSchema = z.object({
symbol: z.string().min(1),
side: z.enum(["long", "short", "spot"]),
size: z.number(),
positionValueUsd: z.number().nonnegative().nullable().optional(),
marginUsedUsd: z.number().nonnegative().nullable().optional(),
leverage: z.number().positive().nullable().optional(),
liquidationPrice: z.number().positive().nullable().optional(),
entryPrice: z.number().positive().nullable().optional(),
markPrice: z.number().positive().nullable().optional(),
});
export const generateTradeSchema = z.object({
symbol: z.string().min(1),
marketSymbol: z.string().min(1).optional(),
currentMarkPrice: z.number().positive().optional(),
availableToTrade: z.number().positive().optional(),
accountValue: z.number().positive().optional(),
leverage: z.number().positive().optional(),
leverageMode: z.enum(["cross", "isolated"]).optional(),
openPositions: z.array(generateTradeOpenPositionSchema).max(20).optional(),
environment: z.enum(["mainnet", "testnet"]).default("mainnet"),
});
type GenerateTradeInput = z.infer<typeof generateTradeSchema>;
type GenerateTradeSide = "buy" | "sell";
type GeneratedCandidate = {
side: GenerateTradeSide;
orderType: "limit";
limitPrice: number;
notionalUsd: number;
leverage: number | null;
leverageMode: "cross" | "isolated" | null;
takeProfit: {
triggerPrice: number;
execution: "limit";
limitPrice: number;
};
stopLoss: {
triggerPrice: number;
execution: "limit";
limitPrice: number;
};
};
type CandidateReview = {
candidate: GeneratedCandidate;
review: ReviewTradeResult;
score: number;
};
export type GenerateTradeResult = {
ok: true;
generatedAt: string;
asset: string;
marketSymbol: string;
environment: GenerateTradeInput["environment"];
tradeIdea: {
side: GenerateTradeSide;
conviction: "proceed" | "reduce_size" | "wait" | "avoid";
canApply: boolean;
orderType: "limit";
entryPrice: number;
currentPrice: number;
notionalUsd: number;
leverage: number | null;
leverageMode: "cross" | "isolated" | null;
takeProfit: {
triggerPrice: number;
execution: "limit";
limitPrice: number;
};
stopLoss: {
triggerPrice: number;
execution: "limit";
limitPrice: number;
};
riskRewardRatio: number | null;
};
recommendation: ReviewTradeResult["recommendation"];
technicalContext: ReviewTradeResult["technicalContext"];
recentManualTradeContext: ReviewTradeResult["recentManualTradeContext"];
riskContext: ReviewTradeResult["riskContext"];
sessionContext: ReviewTradeResult["sessionContext"];
newsContext: ReviewTradeResult["newsContext"];
preferencesApplied: ReviewTradeResult["preferencesApplied"];
alternates: Array<{
side: GenerateTradeSide;
action: ReviewTradeResult["recommendation"]["action"];
confidence: number;
headline: string;
}>;
warnings: string[];
};
type LocalTechnicalSnapshot = {
currentPrice: number;
change24hPct: number | null;
rsi14: number | null;
macdHistogram: number | null;
};
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function normalizeAssetSymbol(value: string) {
const normalized = normalizeHyperliquidBaseSymbol(value);
if (normalized) return normalized.toUpperCase();
const trimmed = value.trim().toUpperCase();
if (trimmed.includes("/")) {
return trimmed.split("/")[0] ?? trimmed;
}
if (trimmed.includes("-")) {
return trimmed.split("-")[0] ?? trimmed;
}
return trimmed;
}
function roundPrice(value: number) {
if (!Number.isFinite(value) || value <= 0) return value;
const decimals = value >= 10_000 ? 0 : value >= 1_000 ? 1 : value >= 1 ? 2 : value >= 0.1 ? 4 : 6;
return Number.parseFloat(value.toFixed(decimals));
}
function roundUsd(value: number) {
return Number.parseFloat(clamp(value, 10, 5_000).toFixed(2));
}
function isLikelySpotMarket(value: string | null | undefined) {
const normalized = value?.trim().toUpperCase() ?? "";
return normalized.includes("/") || normalized.startsWith("SPOT:");
}
function buildSessionMultiplier(nowIso: string) {
const hourUtc = new Date(nowIso).getUTCHours();
if (hourUtc < 7) return 0.8;
if (hourUtc < 13) return 0.95;
if (hourUtc < 21) return 1;
return 0.85;
}
function buildDirectionalPrice(params: {
side: GenerateTradeSide;
price: number;
percent: number;
}) {
const direction = params.side === "buy" ? 1 : -1;
return roundPrice(params.price * (1 + (direction * params.percent) / 100));
}
function scoreAction(action: ReviewTradeResult["recommendation"]["action"]) {
if (action === "proceed") return 4;
if (action === "reduce_size") return 3;
if (action === "wait") return 1.5;
return 0;
}
function candidateDirectionalBonus(params: {
side: GenerateTradeSide;
technical: LocalTechnicalSnapshot;
}) {
let bonus = 0;
if (params.side === "buy") {
if (params.technical.rsi14 != null && params.technical.rsi14 <= 40) bonus += 0.35;
if (params.technical.macdHistogram != null && params.technical.macdHistogram > 0) bonus += 0.25;
} else {
if (params.technical.rsi14 != null && params.technical.rsi14 >= 60) bonus += 0.35;
if (params.technical.macdHistogram != null && params.technical.macdHistogram < 0) bonus += 0.25;
}
return bonus;
}
function buildLocalTechnicalSnapshot(closes: number[]): LocalTechnicalSnapshot {
const currentPrice = closes[closes.length - 1] ?? 0;
const changeAnchor = closes.length >= 25 ? closes[closes.length - 25] : closes[0] ?? null;
const change24hPct =
changeAnchor && changeAnchor > 0
? ((currentPrice - changeAnchor) / changeAnchor) * 100
: null;
const macd = computeMacd(closes);
return {
currentPrice,
change24hPct,
rsi14: computeRsi(closes),
macdHistogram: macd?.histogram ?? null,
};
}
function resolveBaseNotional(params: {
input: GenerateTradeInput;
riskProfile: "conservative" | "balanced" | "aggressive";
sessionMultiplier: number;
}) {
const availableCapital = params.input.availableToTrade ?? params.input.accountValue ?? 150;
const riskFraction =
params.riskProfile === "conservative"
? 0.08
: params.riskProfile === "aggressive"
? 0.16
: 0.12;
const maxNotional =
params.riskProfile === "conservative"
? 350
: params.riskProfile === "aggressive"
? 1_200
: 700;
return roundUsd(Math.min(availableCapital * riskFraction * params.sessionMultiplier, maxNotional));
}
function resolveSuggestedLeverage(params: {
input: GenerateTradeInput;
riskProfile: "conservative" | "balanced" | "aggressive";
allowHighLeverage: boolean;
isSpotMarket: boolean;
}) {
if (params.isSpotMarket) return null;
if (params.input.leverage != null && Number.isFinite(params.input.leverage)) {
return Math.max(1, Math.round(params.input.leverage));
}
if (params.riskProfile === "conservative") return 2;
if (params.riskProfile === "aggressive") {
return params.allowHighLeverage ? 7 : 5;
}
return params.allowHighLeverage ? 5 : 3;
}
function buildCandidate(params: {
side: GenerateTradeSide;
input: GenerateTradeInput;
technical: LocalTechnicalSnapshot;
notionalUsd: number;
leverage: number | null;
}) : GeneratedCandidate {
const basePrice = params.input.currentMarkPrice ?? params.technical.currentPrice;
const volatilityPct = clamp(Math.abs(params.technical.change24hPct ?? 1.8), 1.2, 4.8);
const entryBufferPct =
params.side === "buy"
? params.technical.rsi14 != null && params.technical.rsi14 <= 38
? 0.12
: params.technical.macdHistogram != null && params.technical.macdHistogram > 0
? 0.04
: 0.18
: params.technical.rsi14 != null && params.technical.rsi14 >= 62
? 0.12
: params.technical.macdHistogram != null && params.technical.macdHistogram < 0
? 0.04
: 0.18;
const takeProfitPct = clamp(volatilityPct * 0.7, 1.1, 3.5);
const stopLossPct = clamp(takeProfitPct / 2, 0.55, 1.75);
const limitPrice = buildDirectionalPrice({
side: params.side,
price: basePrice,
percent: params.side === "buy" ? -entryBufferPct : entryBufferPct,
});
const takeProfitPrice = buildDirectionalPrice({
side: params.side,
price: limitPrice,
percent: takeProfitPct,
});
const stopLossPrice = buildDirectionalPrice({
side: params.side === "buy" ? "sell" : "buy",
price: limitPrice,
percent: stopLossPct,
});
return {
side: params.side,
orderType: "limit",
limitPrice,
notionalUsd: params.notionalUsd,
leverage: params.leverage,
leverageMode: params.leverage ? (params.input.leverageMode ?? "cross") : null,
takeProfit: {
triggerPrice: takeProfitPrice,
execution: "limit",
limitPrice: takeProfitPrice,
},
stopLoss: {
triggerPrice: stopLossPrice,
execution: "limit",
limitPrice: stopLossPrice,
},
};
}
async function reviewCandidate(params: {
input: GenerateTradeInput;
candidate: GeneratedCandidate;
}) : Promise<CandidateReview> {
const review = await reviewTrade({
symbol: params.input.symbol,
marketSymbol: params.input.marketSymbol,
side: params.candidate.side,
notionalUsd: params.candidate.notionalUsd,
orderType: params.candidate.orderType,
limitPrice: params.candidate.limitPrice,
currentMarkPrice: params.input.currentMarkPrice,
leverage: params.candidate.leverage ?? undefined,
leverageMode: params.candidate.leverageMode ?? undefined,
takeProfit: params.candidate.takeProfit,
stopLoss: params.candidate.stopLoss,
openPositions: params.input.openPositions,
environment: params.input.environment,
});
const score =
scoreAction(review.recommendation.action) +
(review.recommendation.confidence ?? 0.5) +
(review.riskContext.riskRewardRatio != null
? clamp(review.riskContext.riskRewardRatio, 0, 2.5) * 0.2
: 0) +
candidateDirectionalBonus({
side: params.candidate.side,
technical: {
currentPrice: review.technicalContext.currentPrice,
change24hPct: review.technicalContext.change24hPct ?? null,
rsi14: review.technicalContext.rsi14 ?? null,
macdHistogram: review.technicalContext.macd?.histogram ?? null,
},
});
return {
candidate: params.candidate,
review,
score,
};
}
function scaleNotionalForConviction(params: {
notionalUsd: number;
action: ReviewTradeResult["recommendation"]["action"];
}) {
const multiplier =
params.action === "proceed"
? 1
: params.action === "reduce_size"
? 0.65
: params.action === "wait"
? 0.4
: 0.25;
return roundUsd(params.notionalUsd * multiplier);
}
export async function generateTrade(
input: GenerateTradeInput,
): Promise<GenerateTradeResult> {
const generatedAt = new Date().toISOString();
const config = await readConfig();
const reviewPreferences = resolveTradeReviewConfig(config);
const marketSymbol = input.marketSymbol?.trim() || input.symbol.trim();
const asset = normalizeAssetSymbol(marketSymbol);
const closes = await fetchGatewayHyperliquidCloses({
symbol: marketSymbol,
environment: input.environment,
countBack: 80,
});
if (closes.length === 0) {
throw new Error(`No Hyperliquid price bars returned for ${marketSymbol}.`);
}
const technical = buildLocalTechnicalSnapshot(closes);
const sessionMultiplier = buildSessionMultiplier(generatedAt);
const isSpotMarket = isLikelySpotMarket(marketSymbol);
const baseNotional = resolveBaseNotional({
input,
riskProfile: reviewPreferences.riskProfile,
sessionMultiplier,
});
const suggestedLeverage = resolveSuggestedLeverage({
input,
riskProfile: reviewPreferences.riskProfile,
allowHighLeverage: reviewPreferences.allowHighLeverage,
isSpotMarket,
});
const candidates = [
buildCandidate({
side: "buy",
input,
technical,
notionalUsd: baseNotional,
leverage: suggestedLeverage,
}),
buildCandidate({
side: "sell",
input,
technical,
notionalUsd: baseNotional,
leverage: suggestedLeverage,
}),
];
const reviewedCandidates = await Promise.all(
candidates.map((candidate) => reviewCandidate({ input, candidate })),
);
const sorted = [...reviewedCandidates].sort((left, right) => right.score - left.score);
const selected = sorted[0];
if (!selected) {
throw new Error("Failed to generate a trade candidate.");
}
const conviction = selected.review.recommendation.action;
const scaledNotionalUsd = scaleNotionalForConviction({
notionalUsd: selected.candidate.notionalUsd,
action: conviction,
});
return {
ok: true,
generatedAt,
asset: selected.review.asset,
marketSymbol: selected.review.marketSymbol,
environment: input.environment,
tradeIdea: {
side: selected.candidate.side,
conviction,
canApply: conviction === "proceed" || conviction === "reduce_size",
orderType: selected.candidate.orderType,
entryPrice: selected.candidate.limitPrice,
currentPrice: selected.review.technicalContext.currentPrice,
notionalUsd: scaledNotionalUsd,
leverage: selected.candidate.leverage,
leverageMode: selected.candidate.leverageMode,
takeProfit: selected.candidate.takeProfit,
stopLoss: selected.candidate.stopLoss,
riskRewardRatio: selected.review.riskContext.riskRewardRatio ?? null,
},
recommendation: {
...selected.review.recommendation,
summary:
conviction === "reduce_size"
? `${selected.review.recommendation.summary} Size was cut automatically for this generated setup.`
: selected.review.recommendation.summary,
},
technicalContext: selected.review.technicalContext,
recentManualTradeContext: selected.review.recentManualTradeContext,
riskContext: selected.review.riskContext,
sessionContext: selected.review.sessionContext,
newsContext: selected.review.newsContext,
preferencesApplied: selected.review.preferencesApplied,
alternates: sorted.slice(1).map((entry) => ({
side: entry.candidate.side,
action: entry.review.recommendation.action,
confidence: entry.review.recommendation.confidence,
headline: entry.review.recommendation.headline,
})),
warnings: selected.review.warnings,
};
}