2Branches0Tags
GL
glucryptoFix trade candle loading via gateway
typescript
import { createAIClient } from "opentool/ai"; import { normalizeHyperliquidBaseSymbol } from "opentool/adapters/hyperliquid"; import { z } from "zod"; import { collectStreamText } from "./ai"; import { readConfig, resolveTradeReviewConfig } from "./config"; import { fetchGatewayHyperliquidCloses } from "./hyperliquid-bars"; import { computeMacd, type MacdResult } from "./indicators/computeMacd"; import { computeRsi } from "./indicators/computeRsi"; import { gatewayRequest, openpondRequest } from "./openpond"; const reviewTradeSideSchema = z.enum(["buy", "sell", "long", "short"]); const reviewTradeRiskLegSchema = z.object({ triggerPrice: z.number().positive(), execution: z.enum(["market", "limit"]), limitPrice: z.number().positive().optional(), }); const reviewTradeOpenPositionSchema = 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 reviewTradeSchema = z.object({ symbol: z.string().min(1), marketSymbol: z.string().min(1).optional(), side: reviewTradeSideSchema, notionalUsd: z.number().positive(), orderType: z.enum(["market", "limit"]).default("market"), limitPrice: z.number().positive().optional(), currentMarkPrice: z.number().positive().optional(), environment: z.enum(["mainnet", "testnet"]).default("mainnet"), leverage: z.number().positive().optional(), leverageMode: z.enum(["cross", "isolated"]).optional(), estimatedLiquidationPrice: z.number().positive().optional(), liquidationDistancePct: z.number().nonnegative().optional(), takeProfit: reviewTradeRiskLegSchema.optional(), stopLoss: reviewTradeRiskLegSchema.optional(), openPositions: z.array(reviewTradeOpenPositionSchema).max(20).optional(), recentTrades: z .array( z.object({ submittedAt: z.string(), filledAt: z.string().nullable().optional(), symbol: z.string(), side: z.string().optional(), requestedNotional: z.number().nullable().optional(), avgFillPx: z.number().nullable().optional(), totalFee: z.number().nullable().optional(), status: z.string().optional(), environment: z.string().nullable().optional(), metadata: z.record(z.string(), z.unknown()).nullable().optional(), }), ) .max(60) .optional(), }); type ReviewTradeInput = z.infer<typeof reviewTradeSchema>; type RecentTradeSnapshot = NonNullable<ReviewTradeInput["recentTrades"]>[number]; type ReviewTradeRiskLeg = NonNullable<ReviewTradeInput["takeProfit"]>; type ReviewAction = "proceed" | "reduce_size" | "wait" | "avoid"; type ReviewNewsArticle = { articleId: string; title: string; sourceName: string; canonicalUrl: string | null; publishedAt: string | null; reason: string; }; type ReviewNewsContext = { available: boolean; connectedApp?: string | null; mode: "recent_feed"; query: string | null; summary: string | null; topArticles: ReviewNewsArticle[]; matchedEventCount: number; bias: "bullish" | "bearish" | "mixed" | "neutral"; severity: "major" | "normal" | "none"; warning?: string | null; }; type ReviewTechnicalContext = { currentPrice: number; proposedPrice: number; priceDeltaPct: number; change24hPct: number | null; rsi14: number | null; macd: MacdResult | null; }; type ReviewRecentTradeContext = { totalReviewed: number; sameAssetTrades: number; sameSideTrades: number; oppositeSideTrades: number; averageFillPrice: number | null; averageNotionalUsd: number | null; lastTradeAt: string | null; sameAssetRealizedNetPnlUsd: number | null; sameAssetWinningTrades: number; sameAssetLosingTrades: number; recentLossStreak: number; cooldownSignal: "none" | "watch" | "break"; }; type ReviewRiskContext = { proposedLeverage: number | null; leverageMode: "cross" | "isolated" | null; estimatedLiquidationPrice: number | null; liquidationDistancePct: number | null; totalOpenPositions: number; otherOpenPositions: number; totalOpenExposureUsd: number | null; sameAssetPosition: | { side: "long" | "short" | "spot"; size: number; leverage: number | null; liquidationPrice: number | null; positionValueUsd: number | null; } | null; takeProfitValid: boolean | null; stopLossValid: boolean | null; takeProfitDistancePct: number | null; stopLossDistancePct: number | null; riskRewardRatio: number | null; }; type ReviewSessionContext = { reviewedAt: string; weekday: string; hourUtc: number; sessionLabel: "asia" | "europe" | "us" | "late"; }; type ReviewRecommendation = { action: ReviewAction; confidence: number; headline: string; summary: string; reasons: string[]; }; type ReviewPreferences = ReturnType<typeof resolveTradeReviewConfig>; export type ReviewTradeResult = { ok: true; reviewedAt: string; asset: string; marketSymbol: string; environment: ReviewTradeInput["environment"]; order: { side: "buy" | "sell"; orderType: ReviewTradeInput["orderType"]; notionalUsd: number; proposedPrice: number; takeProfit?: ReviewTradeRiskLeg; stopLoss?: ReviewTradeRiskLeg; }; recommendation: ReviewRecommendation; priceContext: ReviewTechnicalContext; technicalContext: ReviewTechnicalContext; recentManualTradeContext: ReviewRecentTradeContext; riskContext: ReviewRiskContext; sessionContext: ReviewSessionContext; newsContext: ReviewNewsContext; preferencesApplied: ReviewPreferences; warnings: string[]; }; function stripTrailingPeriod(value: string) { return value.trim().replace(/[.]+$/, ""); } function lowerCaseFirst(value: string) { return value.length > 0 ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : value; } function buildFallbackRecommendationSummary(params: { action: ReviewAction; asset: string; side: "buy" | "sell"; reasons: string[]; }) { const fragments = params.reasons .slice(0, 2) .map((reason, index) => { const cleaned = stripTrailingPeriod(reason); return index === 0 ? lowerCaseFirst(cleaned) : cleaned; }); if (fragments.length === 0) { if (params.action === "proceed") { return `Momentum, risk structure, and your recent ${params.asset} trade behavior all support this ${params.side}.`; } if (params.action === "reduce_size") { return `There is a workable setup here, but the structure still argues for smaller size than usual.`; } if (params.action === "wait") { return `There is some support for this trade, but the setup is not clean enough to justify acting right now.`; } return `The setup is too aggressive right now and conflicts with your recent trading behavior.`; } const joined = fragments.length === 1 ? fragments[0] : `${fragments[0]}, and ${fragments[1]}`; if (params.action === "proceed") { return `This ${params.side} still looks supportable because ${joined}.`; } if (params.action === "reduce_size") { return `The idea has some support, but it should stay smaller because ${joined}.`; } if (params.action === "wait") { return `This setup needs cleaner confirmation because ${joined}.`; } return `This trade should be avoided for now because ${joined}.`; } const aiRecommendationSchema = z.object({ action: z.enum(["proceed", "reduce_size", "wait", "avoid"]), confidence: z.number().min(0.5).max(0.95), headline: z.string().min(1), summary: z.string().min(1), reasons: z.array(z.string().min(1)).min(2).max(3), }); type OpenpondAppsListResponse = { apps?: Array<{ id?: string; gitRepo?: string | null; repo?: string | null; name?: string | null; }>; }; type OpenpondTradeFactsResponse = { trades?: RecentTradeSnapshot[]; }; type LiveFeedItem = { articleId?: string; title?: string; summary?: string | null; sourceName?: string; canonicalUrl?: string | null; publishedAt?: string | null; mapping?: { eventId?: string | null; eventKey?: string | null; eventTitle?: string | null; } | null; }; type LiveFeedResponse = { items?: LiveFeedItem[]; }; type LoadedRecentTrades = { trades: RecentTradeSnapshot[]; warning?: string | null; }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function normalizeSide(value: ReviewTradeInput["side"]): "buy" | "sell" { return value === "sell" || value === "short" ? "sell" : "buy"; } 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 matchesAsset(symbol: string, target: string) { return normalizeAssetSymbol(symbol) === normalizeAssetSymbol(target); } function isRecentTradeSnapshot(value: unknown): value is RecentTradeSnapshot { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } const trade = value as Record<string, unknown>; return ( typeof trade.submittedAt === "string" && typeof trade.symbol === "string" ); } const MANUAL_APP_REPO_NAMES = new Set(["manual", "hyperliquid-default"]); async function resolveHyperliquidManualAppId(): Promise<string | null> { const response = await openpondRequest("/apps/list", { method: "GET", headers: { Accept: "application/json" }, }); if (!response.ok) { return null; } const payload = response.data && typeof response.data === "object" && !Array.isArray(response.data) ? (response.data as OpenpondAppsListResponse) : null; const app = (payload?.apps ?? []).find((candidate) => { const repoName = candidate?.gitRepo?.trim() || candidate?.repo?.trim() || candidate?.name?.trim() || ""; return MANUAL_APP_REPO_NAMES.has(repoName.toLowerCase()); }); return app?.id?.trim() || null; } async function loadRecentManualTrades( environment: ReviewTradeInput["environment"], ): Promise<LoadedRecentTrades> { const appId = await resolveHyperliquidManualAppId(); if (!appId) { return { trades: [], warning: "Could not find your manual app for recent trade review.", }; } const query = new URLSearchParams({ appId, limit: "60", environment, highSignalOnly: "true", }); const response = await openpondRequest(`/apps/trade-facts?${query.toString()}`, { method: "GET", headers: { Accept: "application/json" }, }); if (!response.ok) { return { trades: [], warning: "Could not load recent Hyperliquid manual trades for this review.", }; } const payload = response.data && typeof response.data === "object" && !Array.isArray(response.data) ? (response.data as OpenpondTradeFactsResponse) : null; return { trades: (payload?.trades ?? []).filter(isRecentTradeSnapshot), warning: null, }; } function resolveReviewEnvironmentValues( environment: ReviewTradeInput["environment"], ): string[] { return environment === "mainnet" ? ["mainnet", "hyperliquid"] : ["testnet", "hyperliquid-testnet"]; } function toTradeSortTimestamp(trade: RecentTradeSnapshot) { return Date.parse(trade.filledAt ?? trade.submittedAt); } function readTradeRealizedNetPnlUsd(trade: RecentTradeSnapshot) { const metadata = trade.metadata ?? null; if (!metadata || typeof metadata !== "object") return null; const record = metadata as Record<string, unknown>; const realizedNet = record.realizedNetPnlUsd; if (typeof realizedNet === "number" && Number.isFinite(realizedNet)) { return realizedNet; } if (typeof realizedNet === "string" && realizedNet.trim().length > 0) { const parsed = Number.parseFloat(realizedNet); if (Number.isFinite(parsed)) return parsed; } const totalNet = record.totalNetPnlUsd; if (typeof totalNet === "number" && Number.isFinite(totalNet)) { return totalNet; } if (typeof totalNet === "string" && totalNet.trim().length > 0) { const parsed = Number.parseFloat(totalNet); if (Number.isFinite(parsed)) return parsed; } return null; } function buildSessionContext(reviewedAtIso: string): ReviewSessionContext { const reviewedAt = new Date(reviewedAtIso); const hourUtc = reviewedAt.getUTCHours(); const weekday = reviewedAt.toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC", }); const sessionLabel = hourUtc < 7 ? "asia" : hourUtc < 13 ? "europe" : hourUtc < 21 ? "us" : "late"; return { reviewedAt: reviewedAtIso, weekday, hourUtc, sessionLabel, }; } function summarizeRecentTrades( trades: ReviewTradeInput["recentTrades"], params: { asset: string; side: "buy" | "sell"; environment: ReviewTradeInput["environment"] }, ): ReviewRecentTradeContext { const recentTrades = Array.isArray(trades) ? trades : []; const allowedEnvironments = resolveReviewEnvironmentValues(params.environment); const sameAsset = recentTrades.filter( (trade) => matchesAsset(trade.symbol, params.asset) && (!trade.environment || allowedEnvironments.includes(trade.environment)), ); const sameSideTrades = sameAsset.filter((trade) => { const tradeSide = (trade.side ?? "").toLowerCase(); return params.side === "buy" ? tradeSide.includes("buy") || tradeSide.includes("long") : tradeSide.includes("sell") || tradeSide.includes("short"); }); const oppositeSideTrades = sameAsset.filter((trade) => !sameSideTrades.includes(trade)); const pricedTrades = sameAsset.filter((trade) => trade.avgFillPx != null); const notionalTrades = sameAsset.filter((trade) => trade.requestedNotional != null); const averageFillPrice = pricedTrades.length ? pricedTrades.reduce((sum, trade) => sum + (trade.avgFillPx ?? 0), 0) / pricedTrades.length : null; const averageNotionalUsd = notionalTrades.length ? notionalTrades.reduce((sum, trade) => sum + (trade.requestedNotional ?? 0), 0) / notionalTrades.length : null; const pnlTrades = sameAsset .map((trade) => ({ trade, realizedNetPnlUsd: readTradeRealizedNetPnlUsd(trade), sortTs: toTradeSortTimestamp(trade), })) .filter((entry) => entry.realizedNetPnlUsd != null); const sameAssetRealizedNetPnlUsd = pnlTrades.length ? pnlTrades.reduce((sum, entry) => sum + (entry.realizedNetPnlUsd ?? 0), 0) : null; const sameAssetWinningTrades = pnlTrades.filter( (entry) => (entry.realizedNetPnlUsd ?? 0) > 0, ).length; const sameAssetLosingTrades = pnlTrades.filter( (entry) => (entry.realizedNetPnlUsd ?? 0) < 0, ).length; let recentLossStreak = 0; for (const entry of [...pnlTrades].sort((left, right) => right.sortTs - left.sortTs)) { if ((entry.realizedNetPnlUsd ?? 0) < 0) { recentLossStreak += 1; continue; } break; } const cooldownSignal: ReviewRecentTradeContext["cooldownSignal"] = recentLossStreak >= 3 || (sameAssetRealizedNetPnlUsd != null && sameAssetRealizedNetPnlUsd < 0 && sameAssetLosingTrades >= 3) ? "break" : recentLossStreak >= 2 || (sameAssetRealizedNetPnlUsd != null && sameAssetRealizedNetPnlUsd < 0 && sameAssetLosingTrades >= 2) ? "watch" : "none"; const lastTradeAt = sameAsset .map((trade) => trade.submittedAt) .sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null; return { totalReviewed: recentTrades.length, sameAssetTrades: sameAsset.length, sameSideTrades: sameSideTrades.length, oppositeSideTrades: oppositeSideTrades.length, averageFillPrice: averageFillPrice != null && Number.isFinite(averageFillPrice) ? averageFillPrice : null, averageNotionalUsd: averageNotionalUsd != null && Number.isFinite(averageNotionalUsd) ? averageNotionalUsd : null, lastTradeAt, sameAssetRealizedNetPnlUsd: sameAssetRealizedNetPnlUsd != null && Number.isFinite(sameAssetRealizedNetPnlUsd) ? sameAssetRealizedNetPnlUsd : null, sameAssetWinningTrades, sameAssetLosingTrades, recentLossStreak, cooldownSignal, }; } function computeDirectionalDistancePct(params: { side: "buy" | "sell"; referencePrice: number; triggerPrice: number; }) { if (!Number.isFinite(params.referencePrice) || params.referencePrice <= 0) return null; if (!Number.isFinite(params.triggerPrice) || params.triggerPrice <= 0) return null; const directionalDistance = params.side === "buy" ? params.triggerPrice - params.referencePrice : params.referencePrice - params.triggerPrice; return (directionalDistance / params.referencePrice) * 100; } function buildRiskContext(params: { input: ReviewTradeInput; asset: string; side: "buy" | "sell"; proposedPrice: number; }): ReviewRiskContext { const openPositions = Array.isArray(params.input.openPositions) ? params.input.openPositions.filter((position) => Number.isFinite(position.size) && position.size !== 0) : []; const sameAssetPosition = openPositions.find((position) => matchesAsset(position.symbol, params.asset)) ?? null; const otherOpenPositions = openPositions.filter( (position) => !matchesAsset(position.symbol, params.asset), ); const totalOpenExposureValues = openPositions .map((position) => position.positionValueUsd ?? null) .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); const totalOpenExposureUsd = totalOpenExposureValues.length ? totalOpenExposureValues.reduce((sum, value) => sum + Math.abs(value), 0) : null; const takeProfitDistancePct = params.input.takeProfit ? computeDirectionalDistancePct({ side: params.side, referencePrice: params.proposedPrice, triggerPrice: params.input.takeProfit.triggerPrice, }) : null; const stopLossDistancePct = params.input.stopLoss ? computeDirectionalDistancePct({ side: params.side === "buy" ? "sell" : "buy", referencePrice: params.proposedPrice, triggerPrice: params.input.stopLoss.triggerPrice, }) : null; const takeProfitValid = takeProfitDistancePct == null ? null : takeProfitDistancePct > 0; const stopLossValid = stopLossDistancePct == null ? null : stopLossDistancePct > 0; const riskRewardRatio = takeProfitDistancePct != null && stopLossDistancePct != null && takeProfitDistancePct > 0 && stopLossDistancePct > 0 ? takeProfitDistancePct / stopLossDistancePct : null; return { proposedLeverage: params.input.leverage ?? null, leverageMode: params.input.leverageMode ?? null, estimatedLiquidationPrice: params.input.estimatedLiquidationPrice ?? null, liquidationDistancePct: params.input.liquidationDistancePct ?? null, totalOpenPositions: openPositions.length, otherOpenPositions: otherOpenPositions.length, totalOpenExposureUsd, sameAssetPosition: sameAssetPosition ? { side: sameAssetPosition.side, size: sameAssetPosition.size, leverage: sameAssetPosition.leverage ?? null, liquidationPrice: sameAssetPosition.liquidationPrice ?? null, positionValueUsd: sameAssetPosition.positionValueUsd ?? null, } : null, takeProfitValid, stopLossValid, takeProfitDistancePct: takeProfitDistancePct != null && Number.isFinite(takeProfitDistancePct) ? Math.abs(takeProfitDistancePct) : null, stopLossDistancePct: stopLossDistancePct != null && Number.isFinite(stopLossDistancePct) ? Math.abs(stopLossDistancePct) : null, riskRewardRatio: riskRewardRatio != null && Number.isFinite(riskRewardRatio) ? riskRewardRatio : null, }; } function buildRecommendation(params: { asset: string; side: "buy" | "sell"; technical: ReviewTechnicalContext; recentTrades: ReviewRecentTradeContext; risk: ReviewRiskContext; news: ReviewNewsContext; preferences: ReviewPreferences; }): ReviewRecommendation { let score = 0; const reasons: string[] = []; const cautions: string[] = []; if (params.side === "buy") { if (params.technical.rsi14 != null && params.technical.rsi14 <= 38) { score += 1; reasons.push(`RSI is cooling at ${params.technical.rsi14.toFixed(1)}.`); } else if (params.technical.rsi14 != null && params.technical.rsi14 >= 68) { score -= 2; cautions.push(`RSI is stretched at ${params.technical.rsi14.toFixed(1)}.`); } if ( params.technical.macd && params.technical.macd.histogram > 0 && params.technical.macd.macd > params.technical.macd.signalLine ) { score += 1; reasons.push("MACD momentum is positive."); } else if (params.technical.macd && params.technical.macd.histogram < 0) { score -= 1; cautions.push("MACD momentum is still negative."); } } else { if (params.technical.rsi14 != null && params.technical.rsi14 >= 62) { score += 1; reasons.push(`RSI is elevated at ${params.technical.rsi14.toFixed(1)}.`); } else if (params.technical.rsi14 != null && params.technical.rsi14 <= 32) { score -= 2; cautions.push(`RSI is already washed out at ${params.technical.rsi14.toFixed(1)}.`); } if ( params.technical.macd && params.technical.macd.histogram < 0 && params.technical.macd.macd < params.technical.macd.signalLine ) { score += 1; reasons.push("MACD momentum is rolling lower."); } else if (params.technical.macd && params.technical.macd.histogram > 0) { score -= 1; cautions.push("MACD momentum is still positive."); } } if (Math.abs(params.technical.priceDeltaPct) >= 1.25) { score -= 0.5; cautions.push("Your proposed price is materially away from the current mark."); } if (params.risk.proposedLeverage != null) { const elevatedLeverageThreshold = params.preferences.riskProfile === "conservative" ? 6 : params.preferences.riskProfile === "aggressive" ? params.preferences.allowHighLeverage ? 18 : 12 : params.preferences.allowHighLeverage ? 12 : 8; const veryHighLeverageThreshold = params.preferences.riskProfile === "conservative" ? 10 : params.preferences.riskProfile === "aggressive" ? params.preferences.allowHighLeverage ? 28 : 18 : params.preferences.allowHighLeverage ? 20 : 15; const leveragePenaltyScale = params.preferences.riskProfile === "conservative" ? 1.2 : params.preferences.riskProfile === "aggressive" && params.preferences.allowHighLeverage ? 0.35 : params.preferences.riskProfile === "aggressive" ? 0.65 : 1; if (params.risk.proposedLeverage >= veryHighLeverageThreshold) { score -= 1.25 * leveragePenaltyScale; cautions.push(`Leverage is very high at ${params.risk.proposedLeverage.toFixed(0)}x.`); } else if (params.risk.proposedLeverage >= elevatedLeverageThreshold) { score -= 0.75 * leveragePenaltyScale; cautions.push(`Leverage is elevated at ${params.risk.proposedLeverage.toFixed(0)}x.`); } } if (params.risk.liquidationDistancePct != null) { if (params.risk.liquidationDistancePct <= 3) { score -= 1.5; cautions.push( `Estimated liquidation is only ${params.risk.liquidationDistancePct.toFixed(1)}% away.`, ); } else if (params.risk.liquidationDistancePct <= 6) { score -= 0.75; cautions.push( `Estimated liquidation is only ${params.risk.liquidationDistancePct.toFixed(1)}% away.`, ); } } if (params.preferences.useRecentTrades) { if (params.recentTrades.cooldownSignal === "break") { score -= 1.5; cautions.push("Your recent same-asset results suggest taking a break before adding more risk."); } else if (params.recentTrades.cooldownSignal === "watch") { score -= 0.75; cautions.push("Your recent same-asset results have been weak, so this needs cleaner timing."); } if (params.recentTrades.sameSideTrades >= 3) { score -= params.recentTrades.sameSideTrades >= 5 ? 1.5 : 1.25; cautions.push(`You have already leaned ${params.side} on this asset multiple times recently.`); } else if (params.recentTrades.sameSideTrades >= 2) { score -= 0.6; cautions.push(`You have already taken this same-side idea recently in ${params.asset}.`); } if ( params.recentTrades.averageFillPrice != null && params.side === "buy" && params.technical.currentPrice > params.recentTrades.averageFillPrice * 1.05 ) { score -= 0.75; cautions.push("You would be buying well above your recent average fill."); } if ( params.recentTrades.averageFillPrice != null && params.side === "sell" && params.technical.currentPrice < params.recentTrades.averageFillPrice * 0.95 ) { score -= 0.75; cautions.push("You would be selling well below your recent average fill."); } } const proposedPositionSide = params.side === "buy" ? "long" : "short"; if (params.risk.sameAssetPosition) { if (params.risk.sameAssetPosition.side === proposedPositionSide) { score -= 1.1; cautions.push( `You already have an open ${params.risk.sameAssetPosition.side} in ${params.asset}.`, ); } else if (params.risk.sameAssetPosition.side !== "spot") { score -= 1; cautions.push( `This trade leans against your existing ${params.risk.sameAssetPosition.side} in ${params.asset}.`, ); } } if (params.risk.otherOpenPositions >= 4) { score -= 0.5; cautions.push( `You already have ${params.risk.otherOpenPositions} other open positions competing for attention.`, ); } if (params.risk.stopLossValid === null) { if ((params.risk.proposedLeverage ?? 1) > 1) { score -= 1.25; cautions.push("This leveraged setup does not have a stop loss configured."); } } else if (!params.risk.stopLossValid) { score -= 1.5; cautions.push("Your stop loss is on the wrong side of the proposed entry."); } else if (params.risk.stopLossDistancePct != null) { if (params.risk.stopLossDistancePct < 0.4) { score -= 0.5; cautions.push( `Your stop loss is very tight at ${params.risk.stopLossDistancePct.toFixed(2)}% from entry.`, ); } else if (params.risk.stopLossDistancePct > 12) { score -= 0.5; cautions.push( `Your stop loss is wide at ${params.risk.stopLossDistancePct.toFixed(1)}% from entry.`, ); } } if (params.risk.takeProfitValid === null) { score -= 0.25; cautions.push("This setup does not have a take profit configured yet."); } else if (!params.risk.takeProfitValid) { score -= 1; cautions.push("Your take profit is on the wrong side of the proposed entry."); } if (params.risk.riskRewardRatio != null) { if (params.risk.riskRewardRatio < 1) { score -= 0.75; cautions.push( `Risk/reward is only ${params.risk.riskRewardRatio.toFixed(2)}x, which is weak for this setup.`, ); } else if (params.risk.riskRewardRatio >= 1.75) { score += 0.25; reasons.push( `Risk/reward is ${params.risk.riskRewardRatio.toFixed(2)}x, which supports the structure.`, ); } } if (params.news.bias === "bullish" && params.news.summary) { score += params.news.severity === "major" ? 0.35 : 0.1; reasons.push(params.news.summary); } else if (params.news.bias === "bearish" && params.news.summary) { score -= params.news.severity === "major" ? 0.7 : 0.2; cautions.push(params.news.summary); } else if (params.news.bias === "mixed" && params.news.summary) { score -= params.news.severity === "major" ? 0.2 : 0.05; cautions.push(params.news.summary); } const action: ReviewAction = score >= 1.5 ? "proceed" : score >= 0.5 ? "reduce_size" : score > -1 ? "wait" : "avoid"; const confidence = clamp(0.52 + Math.abs(score) * 0.1, 0.52, 0.9); const orderedReasons = action === "proceed" ? [...reasons, ...cautions] : [...cautions, ...reasons]; const topReasons = Array.from( new Set(orderedReasons.map((reason) => reason.trim()).filter((reason) => reason.length > 0)), ).slice(0, 4); if (action === "proceed") { return { action, confidence, headline: `Setup supports a ${params.side} in ${params.asset}.`, summary: buildFallbackRecommendationSummary({ action, asset: params.asset, side: params.side, reasons: topReasons, }), reasons: topReasons, }; } if (action === "reduce_size") { return { action, confidence, headline: `The setup is workable, but size should stay smaller.`, summary: buildFallbackRecommendationSummary({ action, asset: params.asset, side: params.side, reasons: topReasons, }), reasons: topReasons, }; } if (action === "wait") { return { action, confidence, headline: `Wait for cleaner confirmation before placing this trade.`, summary: buildFallbackRecommendationSummary({ action, asset: params.asset, side: params.side, reasons: topReasons, }), reasons: topReasons, }; } return { action, confidence, headline: `Avoid this trade as currently proposed.`, summary: buildFallbackRecommendationSummary({ action, asset: params.asset, side: params.side, reasons: topReasons, }), reasons: topReasons, }; } function normalizeNarrativeReasonList(reasons: string[]) { return Array.from( new Set(reasons.map((reason) => reason.trim()).filter((reason) => reason.length > 0)), ).slice(0, 3); } async function narrateRecommendation(params: { asset: string; side: "buy" | "sell"; technical: ReviewTechnicalContext; recentTrades: ReviewRecentTradeContext; risk: ReviewRiskContext; session: ReviewSessionContext; news: ReviewNewsContext; preferences: ReviewPreferences; summaryModel: string; }) { const ai = createAIClient({ baseUrl: process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, "") ?? undefined, }); const text = await collectStreamText({ ai, errorMessage: "AI trade review did not include textual content.", options: { model: params.summaryModel, timeoutMs: 15_000, headers: { "x-openpond-reasoning-effort": "low", }, messages: [ { role: "system", content: "You are a personal trading concierge reviewing a proposed crypto trade. Decide whether the user should proceed, reduce size, wait, or avoid. Return JSON only. Use the evidence bundle exactly as given. Respect the configured preferences: if useRecentTrades is false, ignore past trade behavior and cooldown entirely. If riskProfile is aggressive and allowHighLeverage is true, elevated leverage alone is not enough to block the trade unless liquidation distance, exit structure, or overlap risk is poor. Weigh realized recent outcomes, cooldown/take-a-break risk, leverage, liquidation distance, open-position overlap, TP/SL structure, RSI, MACD, and time-of-day/session context. News matters less than risk and trade behavior unless it is clearly major. Summary should be 2-4 sentences. Reasons should be 2-3 complete, actionable sentences and should not repeat each other.", }, { role: "user", content: JSON.stringify({ preferences: params.preferences, order: { side: params.side, asset: params.asset, }, side: params.side, asset: params.asset, technical: { currentPrice: params.technical.currentPrice, proposedPrice: params.technical.proposedPrice, priceDeltaPct: params.technical.priceDeltaPct, change24hPct: params.technical.change24hPct, rsi14: params.technical.rsi14, macdHistogram: params.technical.macd?.histogram ?? null, }, recentTrades: params.recentTrades, risk: { proposedLeverage: params.risk.proposedLeverage, leverageMode: params.risk.leverageMode, estimatedLiquidationPrice: params.risk.estimatedLiquidationPrice, liquidationDistancePct: params.risk.liquidationDistancePct, totalOpenPositions: params.risk.totalOpenPositions, otherOpenPositions: params.risk.otherOpenPositions, sameAssetPosition: params.risk.sameAssetPosition, takeProfitValid: params.risk.takeProfitValid, stopLossValid: params.risk.stopLossValid, takeProfitDistancePct: params.risk.takeProfitDistancePct, stopLossDistancePct: params.risk.stopLossDistancePct, riskRewardRatio: params.risk.riskRewardRatio, }, session: params.session, news: { summary: params.news.summary, bias: params.news.bias, severity: params.news.severity, topHeadlines: params.news.topArticles.slice(0, 3).map((article) => ({ title: article.title, sourceName: article.sourceName, })), }, }), }, ], generation: { maxTokens: 800, responseFormat: { type: "json_schema", json_schema: { name: "trade_review_narrative", schema: { type: "object", additionalProperties: false, required: ["action", "confidence", "headline", "summary", "reasons"], properties: { action: { type: "string", enum: ["proceed", "reduce_size", "wait", "avoid"], }, confidence: { type: "number", minimum: 0.5, maximum: 0.95, }, headline: { type: "string" }, summary: { type: "string" }, reasons: { type: "array", minItems: 2, maxItems: 3, items: { type: "string" }, }, }, }, }, }, }, }, }); const parsed = aiRecommendationSchema.safeParse(JSON.parse(text)); if (!parsed.success) { throw new Error("AI trade review narrative returned invalid JSON."); } return { action: parsed.data.action, confidence: parsed.data.confidence, headline: parsed.data.headline.trim(), summary: parsed.data.summary.trim(), reasons: normalizeNarrativeReasonList(parsed.data.reasons), }; } function buildNewsBiasSummary(articles: ReviewNewsArticle[]) { const titles = articles.map((article) => article.title.toLowerCase()); const majorRiskOffCount = titles.filter((title) => /\b(war|attack|missile|drone|blockade|strait|oil spike|surge in oil|crisis)\b/.test(title), ).length; const majorRiskOnCount = titles.filter((title) => /\b(ceasefire|rate cut|stimulus|soft landing)\b/.test(title), ).length; const hasRiskOff = titles.some((title) => /\b(war|attack|missile|drone|conflict|oil|inflation|tariff|sanction|blockage|crisis)\b/.test( title, ), ); const hasRiskOn = titles.some((title) => /\b(ceasefire|approval|rate cut|cooling inflation|soft landing|stimulus|inflows|adoption)\b/.test( title, ), ); if (hasRiskOff && hasRiskOn) { return { bias: "mixed" as const, severity: majorRiskOffCount > 0 || majorRiskOnCount > 0 ? ("major" as const) : ("normal" as const), summary: "Recent headlines are mixed and do not clearly support adding risk right here.", }; } if (hasRiskOff) { return { bias: "bearish" as const, severity: majorRiskOffCount > 0 ? ("major" as const) : ("normal" as const), summary: "Recent headlines skew risk-off across macro and geopolitics, which makes this entry less attractive.", }; } if (hasRiskOn) { return { bias: "bullish" as const, severity: majorRiskOnCount > 0 ? ("major" as const) : ("normal" as const), summary: "Recent headlines are broadly supportive for risk, which modestly helps the setup.", }; } return { bias: "neutral" as const, severity: "none" as const, summary: null, }; } async function resolveNewsContext(_asset: string): Promise<ReviewNewsContext> { const response = await gatewayRequest("/v1/news/live-feed", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ limit: 6, maxAgeHours: 1, preset: "balanced", }), }); if (!response.ok) { return { available: false, connectedApp: "News Intelligence", mode: "recent_feed", query: null, summary: null, topArticles: [], matchedEventCount: 0, bias: "neutral", severity: "none", warning: "Could not load the latest saved news for this review.", }; } const payload = response.data && typeof response.data === "object" && !Array.isArray(response.data) ? (response.data as LiveFeedResponse) : null; const items = Array.isArray(payload?.items) ? payload.items : []; const topArticles = items .filter((item) => typeof item?.title === "string" && typeof item?.sourceName === "string") .slice(0, 3) .map((item, index) => ({ articleId: item.articleId?.trim() || `live-feed-${index}`, title: item.title?.trim() || "Untitled article", sourceName: item.sourceName?.trim() || "Unknown source", canonicalUrl: item.canonicalUrl ?? null, publishedAt: item.publishedAt ?? null, reason: item.mapping?.eventTitle?.trim() || item.summary?.trim() || "Recent saved market headline.", })); const matchedEventCount = items.filter((item) => item.mapping?.eventId).length; const newsBias = buildNewsBiasSummary(topArticles); return { available: topArticles.length > 0, connectedApp: "News Intelligence", mode: "recent_feed", query: null, summary: topArticles.length > 0 ? newsBias.summary ?? "Recent headlines do not show a clear bullish or bearish catalyst for this trade." : null, topArticles, matchedEventCount, bias: newsBias.bias, severity: newsBias.severity, warning: topArticles.length > 0 ? null : "No saved headlines were available in the last hour.", }; } export async function reviewTrade( input: ReviewTradeInput, ): Promise<ReviewTradeResult> { const reviewedAt = new Date().toISOString(); const config = await readConfig(); const reviewPreferences = resolveTradeReviewConfig(config); const side = normalizeSide(input.side); const marketSymbol = input.marketSymbol?.trim() || input.symbol.trim(); const asset = normalizeAssetSymbol(marketSymbol); const [closes, loadedRecentTrades, newsContext] = await Promise.all([ fetchGatewayHyperliquidCloses({ symbol: marketSymbol, environment: input.environment, countBack: 80, }), reviewPreferences.useRecentTrades ? Array.isArray(input.recentTrades) ? Promise.resolve({ trades: input.recentTrades, warning: null }) : loadRecentManualTrades(input.environment) : Promise.resolve({ trades: [], warning: null }), resolveNewsContext(asset), ]); if (closes.length === 0) { throw new Error(`No Hyperliquid price bars returned for ${marketSymbol}.`); } const latestPrice = closes[closes.length - 1] ?? 0; const currentPrice = input.currentMarkPrice ?? latestPrice; const proposedPrice = input.orderType === "limit" && input.limitPrice ? input.limitPrice : currentPrice; const changeAnchor = closes.length >= 25 ? closes[closes.length - 25] : closes[0]; const change24hPct = changeAnchor && changeAnchor > 0 ? ((currentPrice - changeAnchor) / changeAnchor) * 100 : null; const technicalContext: ReviewTechnicalContext = { currentPrice, proposedPrice, priceDeltaPct: currentPrice > 0 ? ((proposedPrice - currentPrice) / currentPrice) * 100 : 0, change24hPct, rsi14: computeRsi(closes), macd: computeMacd(closes), }; const recentTrades = loadedRecentTrades.trades; const recentManualTradeContext = summarizeRecentTrades(recentTrades, { asset, side, environment: input.environment, }); const riskContext = buildRiskContext({ input, asset, side, proposedPrice, }); const sessionContext = buildSessionContext(reviewedAt); const fallbackRecommendation = buildRecommendation({ asset, side, technical: technicalContext, recentTrades: recentManualTradeContext, risk: riskContext, news: newsContext, preferences: reviewPreferences, }); const narratedRecommendation = await narrateRecommendation({ asset, side, technical: technicalContext, recentTrades: recentManualTradeContext, risk: riskContext, session: sessionContext, news: newsContext, preferences: reviewPreferences, summaryModel: config.summaryModel ?? "gpt-5-mini", }).catch(() => null); const warnings = [ ...(newsContext.warning ? [newsContext.warning] : []), ...(loadedRecentTrades.warning ? [loadedRecentTrades.warning] : []), ]; return { ok: true, reviewedAt, asset, marketSymbol, environment: input.environment, order: { side, orderType: input.orderType, notionalUsd: input.notionalUsd, proposedPrice, ...(input.takeProfit ? { takeProfit: input.takeProfit } : {}), ...(input.stopLoss ? { stopLoss: input.stopLoss } : {}), }, recommendation: narratedRecommendation ? { ...fallbackRecommendation, action: narratedRecommendation.action, confidence: narratedRecommendation.confidence, headline: narratedRecommendation.headline, summary: narratedRecommendation.summary, reasons: narratedRecommendation.reasons, } : fallbackRecommendation, priceContext: technicalContext, technicalContext, recentManualTradeContext, riskContext, sessionContext, newsContext, preferencesApplied: reviewPreferences, warnings, }; }