Bot & Automation

Market AI

/root/hermes-projects/Market AI

packages/core/src/analysis-engine.ts text
import { DEFAULT_SCANNER_CRITERIA, KNOWN_MEME_TOKENS, RISK_DISCLAIMER, TRADING_MODES } from "./constants.js";
import type {
  CandidateVerdict,
  MemeCoinCandidate,
  MemePairMetrics,
  MemeSignalAnalysis,
  MemeSignalRequest,
  MemeTokenProfile,
  RiskLevel,
  ScannerCriteria,
  SignalAction,
  TrendDirection,
  TradingMode,
} from "./types.js";

function clamp(value: number, min = 0, max = 100): number {
  return Math.max(min, Math.min(max, value));
}

function safeNumber(value: number | undefined, fallback = 0): number {
  return Number.isFinite(value) ? Number(value) : fallback;
}

export function normalizeTokenQuery(input: string): string {
  return input.trim().replace(/[\u200B-\u200D\uFEFF]/g, "");
}

export function inferModeFromText(text: string): TradingMode {
  const lower = text.toLowerCase();
  if (lower.includes("snipe") || lower.includes("baru listing")) return "snipe";
  if (lower.includes("scalp") || lower.includes("cepat")) return "scalp";
  if (lower.includes("aman") || lower.includes("conservative")) return "conservative";
  return "momentum";
}

export function inferTokenQueryFromText(text: string): string {
  const directAddress = text.match(/\b(?:0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})\b/)?.[0];
  if (directAddress) return directAddress;

  const lower = text.toLowerCase();
  const known = KNOWN_MEME_TOKENS.find((token) => lower.includes(token.symbol.toLowerCase()) || lower.includes(token.name.toLowerCase()));
  if (known) return known.tokenAddress;

  return text
    .replace(/buy|sell|beli|jual|analisa|analisis|bagus|coin|token|sekarang|aman|entry|dong|tolong/gi, " ")
    .trim()
    .split(/\s+/)[0]
    ?.toUpperCase() || "BONK";
}

export function buildMockCandidate(input: Partial<MemeCoinCandidate> = {}): MemeCoinCandidate {
  const token: MemeTokenProfile = input.token ?? {
    chainId: "solana",
    tokenAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
    symbol: "BONK",
    name: "Bonk",
    pairAddress: "mock-bonk-sol",
    dexId: "mock",
    url: "https://dexscreener.com/solana/bonk",
  };
  const metrics: MemePairMetrics = input.metrics ?? {
    priceUsd: 0.000023,
    liquidityUsd: 1850000,
    fdv: 2250000000,
    marketCap: 1600000000,
    volumeH24: 43000000,
    buysH24: 18200,
    sellsH24: 15800,
    priceChangeM5: 0.8,
    priceChangeH1: 4.2,
    priceChangeH6: 11.5,
    priceChangeH24: 18.4,
    pairCreatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 180).toISOString(),
  };

  return scoreMemeCandidate({
    id: `${token.chainId}:${token.tokenAddress}`,
    token,
    metrics,
    score: 0,
    verdict: "RESEARCH",
    riskLevel: "High",
    labels: [],
    warnings: [],
    source: "mock-meme-data",
    scannedAt: new Date().toISOString(),
  });
}

export function mergeScannerCriteria(criteria: ScannerCriteria = {}): Required<ScannerCriteria> {
  return {
    chainId: criteria.chainId ?? DEFAULT_SCANNER_CRITERIA.chainId,
    limit: criteria.limit ?? DEFAULT_SCANNER_CRITERIA.limit,
    minLiquidityUsd: criteria.minLiquidityUsd ?? DEFAULT_SCANNER_CRITERIA.minLiquidityUsd,
    minVolumeH24: criteria.minVolumeH24 ?? DEFAULT_SCANNER_CRITERIA.minVolumeH24,
    maxAgeHours: criteria.maxAgeHours ?? DEFAULT_SCANNER_CRITERIA.maxAgeHours,
    minScore: criteria.minScore ?? DEFAULT_SCANNER_CRITERIA.minScore,
  };
}

export function scoreMemeCandidate(candidate: MemeCoinCandidate): MemeCoinCandidate {
  const metrics = candidate.metrics;
  const liquidity = safeNumber(metrics.liquidityUsd);
  const volume = safeNumber(metrics.volumeH24);
  const buys = safeNumber(metrics.buysH24);
  const sells = safeNumber(metrics.sellsH24);
  const h1 = safeNumber(metrics.priceChangeH1);
  const h24 = safeNumber(metrics.priceChangeH24);
  const ageHours = metrics.pairCreatedAt
    ? Math.max(0, (Date.now() - new Date(metrics.pairCreatedAt).getTime()) / 36e5)
    : undefined;

  let score = 0;
  score += clamp(Math.log10(Math.max(liquidity, 1)) * 9, 0, 28);
  score += clamp(Math.log10(Math.max(volume, 1)) * 8, 0, 28);
  score += clamp(((buys + 1) / (sells + 1) - 0.8) * 24, -8, 14);
  score += h1 > 0 && h1 < 35 ? 10 : h1 >= 35 ? -8 : -4;
  score += h24 > -20 && h24 < 80 ? 12 : h24 >= 80 ? -12 : -10;
  score += ageHours === undefined ? -8 : ageHours < 1 ? -15 : ageHours < 24 ? 3 : 8;

  const warnings: string[] = [];
  const labels: string[] = [];

  if (liquidity < 10000) warnings.push("Likuiditas sangat tipis; slippage dan rug-pull risk tinggi.");
  if (volume < 10000) warnings.push("Volume 24h rendah; exit bisa sulit.");
  if (h1 > 40 || h24 > 120) warnings.push("Harga sudah sangat ekstensi; risiko jadi exit liquidity.");
  if (sells > buys * 1.35) warnings.push("Sell pressure lebih dominan dari buy pressure.");
  if (ageHours !== undefined && ageHours < 2) warnings.push("Pair sangat baru; data historis belum cukup.");
  if (!candidate.token.url) warnings.push("Link pair belum tersedia; lakukan verifikasi manual kontrak.");

  if (liquidity >= 100000) labels.push("liquidity-ok");
  if (volume >= 100000) labels.push("volume-active");
  if (buys > sells) labels.push("buy-pressure");
  if (h1 > 0 && h24 > 0) labels.push("positive-momentum");

  const finalScore = Math.round(clamp(score));
  const riskLevel: RiskLevel = warnings.length >= 3 || liquidity < 25000 ? "Extreme" : warnings.length >= 1 ? "High" : "Medium";
  const verdict: CandidateVerdict = finalScore >= 75 && riskLevel !== "Extreme" ? "PRIORITY_WATCH" : finalScore >= 55 ? "RESEARCH" : "SKIP";

  return {
    ...candidate,
    score: finalScore,
    verdict,
    riskLevel,
    labels,
    warnings,
  };
}

export function filterCandidates(candidates: MemeCoinCandidate[], criteria: ScannerCriteria = {}): MemeCoinCandidate[] {
  const merged = mergeScannerCriteria(criteria);

  return candidates
    .filter((candidate) => candidate.token.chainId === merged.chainId)
    .filter((candidate) => candidate.metrics.liquidityUsd >= merged.minLiquidityUsd)
    .filter((candidate) => candidate.metrics.volumeH24 >= merged.minVolumeH24)
    .filter((candidate) => candidate.score >= merged.minScore)
    .filter((candidate) => {
      if (!candidate.metrics.pairCreatedAt) return true;
      const ageHours = (Date.now() - new Date(candidate.metrics.pairCreatedAt).getTime()) / 36e5;
      return ageHours <= merged.maxAgeHours || candidate.metrics.volumeH24 >= merged.minVolumeH24 * 5;
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, merged.limit);
}

function inferTrend(metrics: MemePairMetrics): TrendDirection {
  const h1 = safeNumber(metrics.priceChangeH1);
  const h24 = safeNumber(metrics.priceChangeH24);
  if (h1 > 3 && h24 > 5) return "Bullish";
  if (h1 < -3 && h24 < -8) return "Bearish";
  if (Math.abs(h1) < 2 && Math.abs(h24) < 5) return "Sideways";
  return "Mixed";
}

export function generateMemeSignalAnalysis(request: MemeSignalRequest, candidate: MemeCoinCandidate): MemeSignalAnalysis {
  const modeConfig = TRADING_MODES[request.mode];
  const trend = inferTrend(candidate.metrics);
  const buySetup = candidate.score >= modeConfig.confidenceFloor && trend === "Bullish" && candidate.riskLevel !== "Extreme";
  const sellSetup = trend === "Bearish" || candidate.warnings.length >= 3;
  const action: SignalAction = buySetup ? "BUY" : sellSetup ? "SELL" : "WAIT";
  const confidence = clamp(
    candidate.score + (trend === "Bullish" ? 5 : trend === "Bearish" ? -10 : -4) - (candidate.riskLevel === "Extreme" ? 12 : 0),
    25,
    88,
  );

  return {
    id: `MEME-${crypto.randomUUID().slice(0, 8).toUpperCase()}`,
    candidate,
    mode: request.mode,
    action,
    trend,
    confidence,
    entryPlan:
      action === "BUY"
        ? "Entry bertahap setelah pullback kecil atau retest breakout. Jangan market buy saat candle parabolic."
        : "Tunggu konfirmasi volume, likuiditas, dan candle berikutnya. Jangan paksa entry.",
    stopLossPlan: "Cut loss manual jika harga turun 8%-15%, liquidity turun tajam, atau sell pressure melonjak.",
    takeProfitPlan: "Ambil profit bertahap di +15%, +30%, lalu sisakan runner kecil jika momentum masih sehat.",
    riskLevel: candidate.riskLevel,
    maxPositionSize: modeConfig.maxRiskPercent,
    invalidation:
      "Setup batal jika liquidity drop, volume buy melemah, top holder/whale dump, price impact terlalu besar, atau kontrak tidak lolos verifikasi manual.",
    reasons: [
      `Scanner score ${candidate.score}/100 dengan verdict ${candidate.verdict}.`,
      `Trend ${trend}; H1 ${candidate.metrics.priceChangeH1 ?? 0}% dan H24 ${candidate.metrics.priceChangeH24 ?? 0}%.`,
      `Liquidity $${Math.round(candidate.metrics.liquidityUsd).toLocaleString("en-US")} dan volume 24h $${Math.round(candidate.metrics.volumeH24).toLocaleString("en-US")}.`,
      `Buy/Sell 24h: ${candidate.metrics.buysH24}/${candidate.metrics.sellsH24}.`,
      `Mode ${modeConfig.label}: ${modeConfig.description}`,
    ],
    warnings: candidate.warnings,
    disclaimer: RISK_DISCLAIMER,
    generatedAt: new Date().toISOString(),
  };
}