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(),
};
}