2Branches0Tags
GL
glucryptoFix trade candle loading via gateway
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, }; }