uniswapx-analyzer-mcp
Version:
Model Context Protocol server for UniswapX trading analytics and metrics with cloud deployment support
1,303 lines (1,296 loc) • 110 kB
JavaScript
// build-temp/server.js
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
// build-temp/PackageDataService.js
import dotenv3 from "dotenv";
// build-temp/GoogleDriveApiClient.js
import { google } from "googleapis";
import fs from "fs";
import path from "path";
import { createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { createGunzip } from "zlib";
import os from "os";
var GoogleDriveApiClient = class {
constructor() {
this.folderId = process.env.GOOGLE_DRIVE_FOLDER_ID;
this.apiKey = process.env.GOOGLE_API_KEY;
this.cacheDir = path.join(os.tmpdir(), "uniswapx-cache");
this.drive = null;
this.validateEnvironment();
this.ensureCacheDir();
this.initializeDrive();
}
validateEnvironment() {
if (!this.folderId) {
throw new Error("GOOGLE_DRIVE_FOLDER_ID environment variable is required");
}
if (!this.apiKey) {
throw new Error("GOOGLE_API_KEY environment variable is required");
}
}
ensureCacheDir() {
if (!fs.existsSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
}
initializeDrive() {
this.drive = google.drive({
version: "v3",
auth: this.apiKey
});
}
/**
* List files in the shared Google Drive folder
* @param {string} namePattern - Optional pattern to filter files
* @returns {Promise<Array>} List of files
*/
async listFiles(namePattern = null) {
try {
const query = `'${this.folderId}' in parents and trashed=false`;
const response = await this.drive.files.list({
q: query,
fields: "files(id,name,modifiedTime,size)",
orderBy: "name desc"
});
let files = response.data.files || [];
if (namePattern) {
const regex = new RegExp(namePattern);
files = files.filter((file) => regex.test(file.name));
}
return files;
} catch (error) {
throw new Error(`Failed to list Google Drive files: ${error.message}`);
}
}
/**
* Download a file from Google Drive
* @param {string} fileId - Google Drive file ID
* @param {string} fileName - Local file name
* @param {boolean} isPrefetched - Whether this is a prefetched file (longer cache TTL)
* @returns {Promise<string>} Path to downloaded file
*/
async downloadFile(fileId, fileName, isPrefetched = false) {
try {
const localPath = path.join(this.cacheDir, fileName);
const maxAge = isPrefetched ? 864e5 : 36e5;
if (fs.existsSync(localPath)) {
const stats = fs.statSync(localPath);
const age = Date.now() - stats.mtime.getTime();
if (age < maxAge) {
console.error(`Using cached file: ${fileName} (age: ${Math.round(age / 36e5)}h)`);
return localPath;
}
}
console.error(`Downloading from Google Drive: ${fileName}`);
const response = await this.drive.files.get({
fileId,
alt: "media"
}, { responseType: "stream" });
const writeStream = createWriteStream(localPath);
await pipeline(response.data, writeStream);
return localPath;
} catch (error) {
throw new Error(`Failed to download file ${fileName}: ${error.message}`);
}
}
/**
* Get data for a specific date (only .jsonl.gz files)
* @param {string|Date} date - Date in YYYY-MM-DD format or Date object
* @returns {Promise<Array>} Parsed JSON data
*/
async getDataForDate(date) {
const dateStr = date instanceof Date ? date.toISOString().split("T")[0] : date;
try {
const fileName = `${dateStr}.jsonl.gz`;
const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`);
if (files.length > 0) {
const file = files[0];
const localPath = await this.downloadFile(file.id, fileName);
return this.parseDataFile(localPath, true);
}
throw new Error(`No data file found for date: ${dateStr}`);
} catch (error) {
throw new Error(`Failed to get data for date ${dateStr}: ${error.message}`);
}
}
/**
* Parse a data file (compressed or uncompressed)
* @param {string} filePath - Path to the file
* @param {boolean} isCompressed - Whether the file is gzipped
* @returns {Promise<Array>} Parsed JSON data
*/
async parseDataFile(filePath, isCompressed = false) {
try {
let content;
if (isCompressed) {
const fileStream = fs.createReadStream(filePath);
const gunzip = createGunzip();
const chunks = [];
await pipeline(
fileStream,
gunzip,
async function* (source) {
for await (const chunk of source) {
chunks.push(chunk);
}
}
);
content = Buffer.concat(chunks).toString("utf8");
} else {
content = fs.readFileSync(filePath, "utf8");
}
return content.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
} catch (error) {
throw new Error(`Failed to parse data file ${filePath}: ${error.message}`);
}
}
/**
* Get data for a date range
* @param {string|Date} startDate - Start date
* @param {string|Date} endDate - End date
* @returns {Promise<Array>} Combined data from all dates
*/
async getDataForDateRange(startDate, endDate) {
const start = startDate instanceof Date ? startDate : new Date(startDate);
const end = endDate instanceof Date ? endDate : new Date(endDate);
if (start > end) {
throw new Error("Start date cannot be after end date");
}
const dates = [];
const current = new Date(start);
while (current <= end) {
dates.push(current.toISOString().split("T")[0]);
current.setDate(current.getDate() + 1);
}
console.error(`\u{1F680} Parallel download starting for ${dates.length} files...`);
const allData = [];
const missingDates = [];
const BATCH_SIZE = 6;
for (let i = 0; i < dates.length; i += BATCH_SIZE) {
const batch = dates.slice(i, i + BATCH_SIZE);
console.error(`\u{1F4E6} Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(dates.length / BATCH_SIZE)}: ${batch[0]} to ${batch[batch.length - 1]}`);
const batchPromises = batch.map(async (dateStr) => {
try {
const dateData = await this.getDataForDate(dateStr);
return { dateStr, data: dateData, success: true };
} catch (error) {
console.warn(`\u26A0\uFE0F No data found for ${dateStr}: ${error.message}`);
return { dateStr, data: null, success: false, error: error.message };
}
});
const batchResults = await Promise.allSettled(batchPromises);
batchResults.forEach((result, index) => {
if (result.status === "fulfilled") {
const { dateStr, data, success } = result.value;
if (success && data) {
allData.push(...data);
} else {
missingDates.push(dateStr);
}
} else {
console.error(`\u274C Batch download failed for ${batch[index]}: ${result.reason}`);
missingDates.push(batch[index]);
}
});
}
console.error(`\u2705 Parallel download completed: ${allData.length} orders from ${dates.length - missingDates.length}/${dates.length} files`);
if (missingDates.length > 0) {
console.warn(`\u26A0\uFE0F Missing data for dates: ${missingDates.join(", ")}`);
}
return allData;
}
/**
* Check if data exists for a specific date (only .jsonl.gz files)
* @param {string|Date} date - Date to check
* @returns {Promise<boolean>} Whether data exists
*/
async hasDataForDate(date) {
const dateStr = date instanceof Date ? date.toISOString().split("T")[0] : date;
try {
const fileName = `${dateStr}.jsonl.gz`;
const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`);
return files.length > 0;
} catch (error) {
console.warn(`Error checking data for ${dateStr}: ${error.message}`);
return false;
}
}
/**
* Pre-fetch recent data files for better performance
* @param {number} days - Number of recent days to pre-fetch (default: 7)
* @returns {Promise<Object>} Statistics about the pre-fetch operation
*/
async preFetchRecentData(days = 7) {
console.error(`\u{1F504} Pre-fetching past ${days} days of data...`);
const stats = {
attempted: 0,
downloaded: 0,
cached: 0,
errors: 0,
errorDates: []
};
const endDate = /* @__PURE__ */ new Date();
endDate.setDate(endDate.getDate() - 1);
for (let i = 0; i < days; i++) {
const currentDate = new Date(endDate);
currentDate.setDate(currentDate.getDate() - i);
const dateStr = currentDate.toISOString().split("T")[0];
stats.attempted++;
try {
const fileName = `${dateStr}.jsonl.gz`;
const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`);
if (files.length > 0) {
const file = files[0];
const localPath = path.join(this.cacheDir, fileName);
if (fs.existsSync(localPath)) {
const stats_file = fs.statSync(localPath);
const age = Date.now() - stats_file.mtime.getTime();
if (age < 864e5) {
console.error(` \u2713 ${dateStr} (already cached)`);
stats.cached++;
continue;
}
}
await this.downloadFile(file.id, fileName, true);
console.error(` \u2B07\uFE0F ${dateStr} (downloaded)`);
stats.downloaded++;
} else {
console.warn(` \u26A0\uFE0F ${dateStr} (no data file found)`);
stats.errors++;
stats.errorDates.push(dateStr);
}
} catch (error) {
console.error(` \u274C ${dateStr} (error: ${error.message})`);
stats.errors++;
stats.errorDates.push(dateStr);
}
if (i < days - 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
console.error(`\u2705 Pre-fetch completed: ${stats.downloaded} downloaded, ${stats.cached} cached, ${stats.errors} errors`);
return stats;
}
/**
* Clean up old cached files using hybrid logic
* - .jsonl.gz files: use data date from filename (maxAgeDays)
* - Other files: use fetch time (1 hour)
* @param {number} maxAgeDays - Maximum age in days for data files (default: 30)
* @returns {Promise<Object>} Statistics about the cleanup operation
*/
async cleanupOldCache(maxAgeDays = 30) {
console.error(`\u{1F9F9} Cleaning up cache with hybrid logic:`);
console.error(` \u{1F4C5} Data files (.jsonl.gz): ${maxAgeDays} days by data date`);
console.error(` \u{1F550} Other files: 1 hour by fetch time`);
const stats = {
total: 0,
removed: 0,
kept: 0,
errors: 0,
dataFiles: { total: 0, removed: 0, kept: 0 },
otherFiles: { total: 0, removed: 0, kept: 0 }
};
try {
if (!fs.existsSync(this.cacheDir)) {
console.error(" \u2139\uFE0F Cache directory does not exist");
return stats;
}
const files = fs.readdirSync(this.cacheDir);
const today = /* @__PURE__ */ new Date();
const dataDateCutoff = new Date(today);
dataDateCutoff.setDate(dataDateCutoff.getDate() - maxAgeDays);
const fetchTimeCutoff = Date.now() - 1 * 60 * 60 * 1e3;
for (const file of files) {
stats.total++;
const filePath = path.join(this.cacheDir, file);
try {
const dataFileMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.jsonl\.gz$/);
if (dataFileMatch) {
stats.dataFiles.total++;
const dataDateStr = dataFileMatch[1];
const dataDate = new Date(dataDateStr);
if (dataDate < dataDateCutoff) {
fs.unlinkSync(filePath);
const daysOld = Math.floor((today - dataDate) / (24 * 60 * 60 * 1e3));
console.error(` \u{1F5D1}\uFE0F Removed: ${file} (data date: ${daysOld} days old)`);
stats.removed++;
stats.dataFiles.removed++;
} else {
const daysOld = Math.floor((today - dataDate) / (24 * 60 * 60 * 1e3));
console.error(` \u2705 Kept: ${file} (data date: ${daysOld} days old)`);
stats.kept++;
stats.dataFiles.kept++;
}
} else {
stats.otherFiles.total++;
const fileStat = fs.statSync(filePath);
if (fileStat.mtime.getTime() < fetchTimeCutoff) {
fs.unlinkSync(filePath);
const hoursOld = Math.round((Date.now() - fileStat.mtime.getTime()) / (60 * 60 * 1e3));
console.error(` \u{1F5D1}\uFE0F Removed: ${file} (fetch time: ${hoursOld}h old)`);
stats.removed++;
stats.otherFiles.removed++;
} else {
const hoursOld = Math.round((Date.now() - fileStat.mtime.getTime()) / (60 * 60 * 1e3));
console.error(` \u2705 Kept: ${file} (fetch time: ${hoursOld}h old)`);
stats.kept++;
stats.otherFiles.kept++;
}
}
} catch (error) {
console.warn(` \u26A0\uFE0F Error processing ${file}: ${error.message}`);
stats.errors++;
}
}
console.error(`\u2705 Cleanup completed:`);
console.error(` \u{1F4CA} Total: ${stats.removed} removed, ${stats.kept} kept, ${stats.errors} errors`);
console.error(` \u{1F4C5} Data files: ${stats.dataFiles.removed} removed, ${stats.dataFiles.kept} kept`);
console.error(` \u{1F4C1} Other files: ${stats.otherFiles.removed} removed, ${stats.otherFiles.kept} kept`);
} catch (error) {
console.error(`\u274C Cache cleanup failed: ${error.message}`);
stats.errors++;
}
return stats;
}
/**
* Get cache status and statistics
* @returns {Promise<Object>} Cache status information
*/
async getCacheStatus() {
const status = {
cacheDir: this.cacheDir,
exists: fs.existsSync(this.cacheDir),
files: [],
totalSize: 0,
oldestFile: null,
newestFile: null
};
try {
if (status.exists) {
const files = fs.readdirSync(this.cacheDir);
for (const file of files) {
const filePath = path.join(this.cacheDir, file);
const stats = fs.statSync(filePath);
const fileInfo = {
name: file,
size: stats.size,
modified: stats.mtime.toISOString(),
age: Date.now() - stats.mtime.getTime()
};
status.files.push(fileInfo);
status.totalSize += stats.size;
if (!status.oldestFile || stats.mtime < new Date(status.oldestFile.modified)) {
status.oldestFile = fileInfo;
}
if (!status.newestFile || stats.mtime > new Date(status.newestFile.modified)) {
status.newestFile = fileInfo;
}
}
status.files.sort((a, b) => new Date(b.modified) - new Date(a.modified));
}
} catch (error) {
status.error = error.message;
}
return status;
}
/**
* Clear local cache
*/
clearCache() {
try {
if (fs.existsSync(this.cacheDir)) {
const files = fs.readdirSync(this.cacheDir);
for (const file of files) {
fs.unlinkSync(path.join(this.cacheDir, file));
}
console.error("Cache cleared");
}
} catch (error) {
console.warn(`Failed to clear cache: ${error.message}`);
}
}
};
// build-temp/utils/TokenInfoManager.js
import fs2 from "fs/promises";
import path2 from "path";
import { ethers } from "ethers";
import dotenv from "dotenv";
dotenv.config();
var BUILT_IN_TOKENS = {
ETH: { symbol: "ETH", decimals: 18, name: "Ethereum" },
"0x4200000000000000000000000000000000000006": { symbol: "WETH", decimals: 18, name: "Wrapped Ether" },
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { symbol: "USDC", decimals: 6, name: "USD Coin" },
"0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22": { symbol: "cbETH", decimals: 18, name: "Coinbase Wrapped Staked ETH" },
"0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42": { symbol: "EURC", decimals: 6, name: "EURC" },
"0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452": {
symbol: "WSTETH",
decimals: 18,
name: "Wrapped liquid staked Ether 2.0"
}
};
var ERC20_ABI = [
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function name() view returns (string)"
];
var TokenInfoManager = class {
constructor(options = {}) {
this.rpcUrl = process.env.BASE_RPC_URL || "https://mainnet.base.org";
this.enableOnChainFetch = options.enableOnChainFetch || false;
this.provider = this.enableOnChainFetch ? new ethers.JsonRpcProvider(this.rpcUrl) : null;
this.cache = /* @__PURE__ */ new Map();
this.initialized = false;
this.cachePath = this.getEnvironmentAwareCachePath();
this.loadBuiltInTokens();
}
/**
* Get environment-aware cache path
* @returns {string} Cache file path
*/
getEnvironmentAwareCachePath() {
if (process.env.TOKEN_CACHE_PATH) {
return process.env.TOKEN_CACHE_PATH;
}
const isCloudEnvironment = !!(process.env.PORT || process.env.NODE_ENV === "production" || process.env.GOOGLE_CLOUD_PROJECT || process.env.K_SERVICE);
if (isCloudEnvironment) {
return "/tmp/token-cache.json";
} else {
return "./data/token-cache.json";
}
}
/**
* 載入內建代幣列表到緩存
*/
loadBuiltInTokens() {
Object.entries(BUILT_IN_TOKENS).forEach(([address, tokenInfo]) => {
this.cache.set(address.toLowerCase(), tokenInfo);
});
}
/**
* 初始化管理器,載入緩存文件
*/
async initialize() {
if (this.initialized) return true;
try {
await fs2.mkdir(path2.dirname(this.cachePath), { recursive: true });
const cacheData = await fs2.readFile(this.cachePath, "utf8");
const cacheObj = JSON.parse(cacheData);
Object.entries(cacheObj).forEach(([address, tokenInfo]) => {
const normalizedAddress = address.toLowerCase();
if (!this.cache.has(normalizedAddress)) {
this.cache.set(normalizedAddress, tokenInfo);
}
});
console.log(
`\u{1F4E6} \u8F09\u5165\u4EE3\u5E63\u7DE9\u5B58: ${this.cache.size} \u500B\u4EE3\u5E63 (\u5305\u542B ${Object.keys(BUILT_IN_TOKENS).length} \u500B\u5167\u5EFA\u4EE3\u5E63)`
);
} catch (error) {
console.log(`\u{1F4E6} \u5275\u5EFA\u65B0\u7684\u4EE3\u5E63\u7DE9\u5B58\u6587\u4EF6\uFF0C\u4F7F\u7528 ${Object.keys(BUILT_IN_TOKENS).length} \u500B\u5167\u5EFA\u4EE3\u5E63 (${error.message})`);
if (error.code === "EACCES" || error.code === "ENOENT") {
console.log(`\u26A0\uFE0F \u8B66\u544A: \u7121\u6CD5\u5275\u5EFA\u7DE9\u5B58\u76EE\u9304\uFF0C\u4F7F\u7528\u50C5\u5167\u5B58\u7DE9\u5B58\u6A21\u5F0F`);
}
}
this.initialized = true;
return true;
}
/**
* 保存緩存到文件(排除內建代幣)
*/
async saveCache() {
try {
const cacheToSave = {};
this.cache.forEach((tokenInfo, address) => {
if (!BUILT_IN_TOKENS[address]) {
cacheToSave[address] = tokenInfo;
}
});
await fs2.writeFile(this.cachePath, JSON.stringify(cacheToSave, null, 2));
console.log(`\u{1F4BE} \u4FDD\u5B58\u4EE3\u5E63\u7DE9\u5B58: ${Object.keys(cacheToSave).length} \u500B\u81EA\u5B9A\u7FA9\u4EE3\u5E63`);
} catch (error) {
console.log(`\u26A0\uFE0F \u8B66\u544A: \u4FDD\u5B58\u7DE9\u5B58\u5931\u6557\uFF0C\u7E7C\u7E8C\u4F7F\u7528\u5167\u5B58\u7DE9\u5B58: ${error.message}`);
}
}
/**
* 獲取單個代幣資訊
* @param {string} tokenAddress - 代幣地址
* @returns {Object|null} 代幣資訊 {symbol, decimals, name} 或 null
*/
async fetchTokenInfo(tokenAddress) {
if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") {
return BUILT_IN_TOKENS["ETH"];
}
const normalizedAddress = tokenAddress.toLowerCase();
if (this.cache.has(normalizedAddress)) {
return this.cache.get(normalizedAddress);
}
if (!this.enableOnChainFetch) {
return null;
}
try {
console.log(`\u{1F50D} \u67E5\u8A62\u4EE3\u5E63\u8CC7\u8A0A: ${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)}`);
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
const [symbol, decimals, name] = await Promise.all([contract.symbol(), contract.decimals(), contract.name()]);
if (!symbol || symbol.trim() === "") {
throw new Error("Invalid symbol returned");
}
const tokenInfo = {
symbol,
decimals: Number(decimals) || 18,
name: name || "Unknown Token"
};
this.cache.set(normalizedAddress, tokenInfo);
console.log(`\u2705 \u7372\u53D6\u6210\u529F: ${tokenInfo.symbol} (${tokenInfo.decimals} decimals)`);
return tokenInfo;
} catch (error) {
console.log(`\u26A0\uFE0F \u67E5\u8A62\u5931\u6557: ${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)} - ${error.message}`);
const fallbackInfo = { symbol: "UNKNOWN", decimals: 18, name: "Unknown Token" };
this.cache.set(normalizedAddress, fallbackInfo);
return fallbackInfo;
}
}
/**
* 批量獲取代幣資訊
* @param {Array<string>} tokenAddresses - 代幣地址數組
*/
async batchFetchTokenInfo(tokenAddresses) {
const uniqueAddresses = [
...new Set(
tokenAddresses.filter((addr) => addr && addr !== "ETH" && addr !== "0x0000000000000000000000000000000000000000")
)
].map((addr) => addr.toLowerCase());
const unknownAddresses = uniqueAddresses.filter((addr) => !this.cache.has(addr));
if (unknownAddresses.length === 0) {
console.log(`\u{1F4E6} \u6240\u6709\u4EE3\u5E63\u8CC7\u8A0A\u90FD\u5728\u7DE9\u5B58\u4E2D`);
return;
}
if (!this.enableOnChainFetch) {
console.log(`\u26A1 \u5FEB\u901F\u6A21\u5F0F: \u8DF3\u904E ${unknownAddresses.length} \u500B\u672A\u77E5\u4EE3\u5E63\u7684\u93C8\u4E0A\u67E5\u8A62`);
return;
}
console.log(`\u{1F50D} \u9700\u8981\u67E5\u8A62 ${unknownAddresses.length} \u500B\u65B0\u4EE3\u5E63...`);
const BATCH_SIZE = 5;
for (let i = 0; i < unknownAddresses.length; i += BATCH_SIZE) {
const batch = unknownAddresses.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map((addr) => this.fetchTokenInfo(addr)));
}
}
/**
* 從緩存獲取代幣資訊(同步)
* @param {string} tokenAddress - 代幣地址
* @returns {Object|null} 代幣資訊或 null
*/
getTokenInfo(tokenAddress) {
if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") {
return BUILT_IN_TOKENS["ETH"];
}
const normalizedAddress = tokenAddress.toLowerCase();
return this.cache.get(normalizedAddress) || null;
}
/**
* 檢查代幣是否在緩存中
* @param {string} tokenAddress - 代幣地址
* @returns {boolean}
*/
hasTokenInfo(tokenAddress) {
if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") {
return true;
}
const normalizedAddress = tokenAddress.toLowerCase();
return this.cache.has(normalizedAddress);
}
/**
* 獲取緩存統計資訊
*/
getCacheStats() {
const builtInTokens = Object.keys(BUILT_IN_TOKENS).length;
const totalTokens = this.cache.size;
const customTokens = totalTokens - builtInTokens;
return {
totalTokens,
builtInTokens,
customTokens,
enableOnChainFetch: this.enableOnChainFetch,
initialized: this.initialized
};
}
};
// build-temp/clients/PortusApiClient.js
import dotenv2 from "dotenv";
dotenv2.config();
var PortusApiClient = class {
/**
* 構造函數
* @param {Object} options - 配置選項
* @param {string} options.baseUrl - API 基礎 URL
* @param {string} options.apiKey - API 密鑰
*/
constructor(options = {}) {
this.baseUrl = options.baseUrl || process.env.PORTUS_API_URL;
this.apiKey = options.apiKey || process.env.PORTUS_API_KEY;
this.isAvailable = !!(this.baseUrl && this.apiKey);
if (this.isAvailable) {
console.log(`\u{1F517} Portus API \u5BA2\u6236\u7AEF\u521D\u59CB\u5316
URL: ${this.baseUrl}
\u{1F511} API Key: \u5DF2\u8A2D\u5B9A`);
} else {
console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0CSpeed \u985E\u578B\u6AA2\u6E2C\u5C07\u88AB\u7981\u7528");
}
}
/**
* 獲取訂單的填充數據
* @param {string} orderHash - 訂單哈希
* @returns {Promise<Array>} 填充數據列表
*/
async getOrderFills(orderHash) {
if (!this.isAvailable) {
throw new Error("Portus API \u672A\u914D\u7F6E");
}
try {
console.log(`\u{1F50D} \u5F9E Portus \u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA: ${orderHash}`);
const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453`, {
method: "POST",
headers: {
"x-api-key": `${this.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
orderHashes: [orderHash]
})
});
if (!response.ok) {
throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const fills = data.fills[orderHash] || [];
console.log(` \u2705 \u627E\u5230 ${fills.length} \u500B\u586B\u5145\u6578\u64DA`);
return fills;
} catch (error) {
console.error(`\u274C \u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA\u5931\u6557: ${error.message}`);
throw new Error(`Portus API \u932F\u8AA4: ${error.message}`);
}
}
/**
* 批量獲取多個訂單的填充數據
* @param {string[]} orderHashes - 訂單哈希列表
* @returns {Promise<Object>} 訂單填充數據映射
*/
async getBatchOrderFills(orderHashes) {
if (!this.isAvailable) {
console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u586B\u5145\u6578\u64DA");
return {};
}
try {
console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA: ${orderHashes.length} \u500B\u8A02\u55AE`);
const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453`, {
method: "POST",
headers: {
"x-api-key": `${this.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
orderHashes
})
});
if (!response.ok) {
throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data.fills) {
console.debug("\u672A\u627E\u5230\u6279\u91CF\u8A02\u55AE\u7684\u586B\u5145\u6578\u64DA");
return {};
}
return data.fills;
} catch (error) {
console.error(`\u6279\u91CF\u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA\u5931\u6557: ${error.message}`);
throw error;
}
}
/**
* 獲取訂單的時間數據 (receivedAt 和 pgaBlockAt)
* @param {string[]} orderHashes - 訂單哈希列表
* @returns {Promise<Object>} 訂單時間數據映射,包含 receivedAt 和 pgaBlockAt
*/
async getOrdersTimingData(orderHashes) {
if (!this.isAvailable) {
console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u6642\u9593\u6578\u64DA");
return {};
}
try {
console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u6642\u9593\u6578\u64DA: ${orderHashes.length} \u500B\u8A02\u55AE`);
const response = await fetch(`${this.baseUrl}/uniswapx-orders/8453`, {
method: "POST",
headers: {
"x-api-key": `${this.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
orderHashes
})
});
if (!response.ok) {
throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data.orders) {
console.debug("\u672A\u627E\u5230\u6279\u91CF\u8A02\u55AE\u7684\u6642\u9593\u6578\u64DA");
return {};
}
const receiveTimeMap = {};
for (const [orderHash, orderData] of Object.entries(data.orders)) {
if (orderData && orderData.receivedAt && orderData.pgaBlockAt) {
receiveTimeMap[orderHash] = {
receivedAt: orderData.receivedAt,
pgaBlockAt: orderData.pgaBlockAt
};
}
}
console.log(` \u2705 \u627E\u5230 ${Object.keys(receiveTimeMap).length} \u500B\u8A02\u55AE\u7684\u6642\u9593\u6578\u64DA`);
return receiveTimeMap;
} catch (error) {
console.error(`\u6279\u91CF\u7372\u53D6\u8A02\u55AE\u6642\u9593\u6578\u64DA\u5931\u6557: ${error.message}`);
throw error;
}
}
/**
* 計算訂單時間間隔(共用工具方法)
* 使用公式:pgaBlockAt - receivedAt - 2200ms
* @param {string} receivedAt - 訂單接收時間 (ISO 字符串)
* @param {string} pgaBlockAt - PGA 區塊時間 (ISO 字符串)
* @returns {number} 時間間隔(毫秒)
*/
static calculateOrderTimingInterval(receivedAt, pgaBlockAt) {
try {
if (!receivedAt || !pgaBlockAt) {
throw new Error("\u7F3A\u5C11\u5FC5\u8981\u7684\u6642\u9593\u6233\u53C3\u6578");
}
const receivedTime = new Date(receivedAt);
const pgaTime = new Date(pgaBlockAt);
if (isNaN(receivedTime.getTime()) || isNaN(pgaTime.getTime())) {
throw new Error("\u7121\u6548\u7684\u6642\u9593\u6233\u683C\u5F0F");
}
return pgaTime.getTime() - receivedTime.getTime() - 2200;
} catch (error) {
console.warn(`\u26A0\uFE0F \u8A08\u7B97\u6642\u9593\u9593\u9694\u5931\u6557: ${error.message}`);
return null;
}
}
/**
* 測試 API 連接
* @returns {Promise<boolean>} 連接是否成功
*/
async testConnection() {
if (!this.isAvailable) {
console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u7121\u6CD5\u6E2C\u8A66\u9023\u63A5");
return false;
}
try {
const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453/health`, {
headers: {
"x-api-key": `${this.apiKey}`,
"Content-Type": "application/json"
}
});
if (response.ok) {
console.log("\u2705 Portus API \u9023\u63A5\u6210\u529F");
return true;
} else {
console.warn(`\u26A0\uFE0F Portus API \u9023\u63A5\u7570\u5E38: ${response.status} ${response.statusText}`);
return false;
}
} catch (error) {
console.error(`\u274C Portus API \u9023\u63A5\u6E2C\u8A66\u5931\u6557: ${error.message}`);
return false;
}
}
};
// build-temp/utils/FormattingUtils.js
var FormattingUtils = class {
/**
* Format number with appropriate suffix
* @param {number} num - Number to format
* @param {number} decimals - Decimal places (default: 2)
* @returns {string} Formatted number
*/
static formatNumber(num, decimals = 2) {
if (num >= 1e6) {
return `${(num / 1e6).toFixed(1)}M`;
} else if (num >= 1e4) {
return `${(num / 1e3).toFixed(1)}K`;
} else if (num >= 1e3) {
return num.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
});
}
return num.toFixed(decimals);
}
/**
* Format USD amount with appropriate suffix
* @param {number} num - Amount to format
* @param {number} forceDecimals - Force specific decimal places (optional)
* @returns {string} Formatted USD amount
*/
static formatUSD(num, forceDecimals = null) {
if (forceDecimals !== null) {
if (num >= 1e6) {
return `$${(num / 1e6).toFixed(forceDecimals)}M`;
} else if (num >= 1e4) {
return `$${(num / 1e3).toFixed(forceDecimals)}K`;
}
return `$${num.toLocaleString("en-US", {
minimumFractionDigits: forceDecimals,
maximumFractionDigits: forceDecimals
})}`;
}
if (num >= 1e6) {
return `$${(num / 1e6).toFixed(1)}M`;
} else if (num >= 1e4) {
return `$${(num / 1e3).toFixed(1)}K`;
}
const options = {
minimumFractionDigits: 0,
maximumFractionDigits: 0
};
if (num < 1) {
options.minimumFractionDigits = 2;
options.maximumFractionDigits = 2;
} else if (num < 10) {
options.minimumFractionDigits = 1;
options.maximumFractionDigits = 1;
}
return `$${num.toLocaleString("en-US", options)}`;
}
/**
* Format percentage
* @param {number} num - Ratio (0-1)
* @param {number} forceDecimals - Force specific decimal places (optional)
* @returns {string} Formatted percentage
*/
static formatPercentage(num, forceDecimals = null) {
const percentage = num * 100;
if (forceDecimals !== null) {
return `${percentage.toFixed(forceDecimals)}%`;
}
return `${percentage.toFixed(percentage < 10 ? 2 : 1)}%`;
}
};
// build-temp/analyzers/SolvedTypeAnalyzer.js
var SolvedTypeAnalyzer = class {
/**
* Constructor
* @param {Object} options - Configuration options
* @param {TokenInfoManager} options.tokenInfoManager - Token info manager instance
* @param {PortusApiClient} options.portusApiClient - Portus API client instance
* @param {Object} options.dataAccessor - Data access object (DataAccessManager or GoogleDriveApiClient)
*/
constructor(options = {}) {
this.tokenInfoManager = options.tokenInfoManager;
this.portusApiClient = options.portusApiClient;
this.dataAccessor = options.dataAccessor;
this.PORTUS_ADDRESS = "0xF85E95BEF8F2De7782B0936ca3480C41a4b6C59B".toLowerCase();
}
/**
* Truncate token symbol to max 7 characters
* @param {string} symbol - Token symbol
* @returns {string} Truncated symbol
*/
truncateTokenSymbol(symbol) {
if (!symbol || symbol.length <= 7) return symbol;
return symbol.substring(0, 4) + "...";
}
/**
* Calculate PGA Block
* @param {Object} order - Order object
* @returns {number} PGA Block number
*/
calculatePgaBlock(order) {
const auctionStartBlock = parseInt(order.priorityOrder?.auctionStartBlock || 0);
const auctionTargetBlock = parseInt(order.priorityOrder?.cosignerData?.auctionTargetBlock || 0);
return Math.min(auctionStartBlock, auctionTargetBlock) || 0;
}
/**
* Get output token address
* @param {Object} order - Order data
* @returns {string} Output token address
*/
getOutputToken(order) {
const swapper = order.priorityOrder?.info?.swapper?.toLowerCase() || "";
const userOutput = order.resolvedOrder?.outputs?.find((o) => o.recipient?.toLowerCase() === swapper);
return userOutput ? userOutput.token : order.resolvedOrder?.outputs?.[0]?.token || "N/A";
}
/**
* Get Speed order time interval range
* @param {number} timeInterval - Time interval in milliseconds
* @returns {string} Range identifier
*/
getSpeedIntervalRange(timeInterval) {
if (timeInterval < 0) return "<0ms";
if (timeInterval < 50) return "<50ms";
if (timeInterval < 100) return "<100ms";
if (timeInterval < 200) return "<200ms";
if (timeInterval < 400) return "<400ms";
return "\u2265400ms";
}
/**
* Get Resolved order BlockLag range
* @param {number} blockLag - Block lag value
* @returns {string} Range identifier
*/
getResolvedBlockLagRange(blockLag) {
if (blockLag === 1) return "+1";
if (blockLag === 2) return "+2";
if (blockLag === 3) return "+3";
if (blockLag === 4) return "+4";
if (blockLag >= 5 && blockLag <= 6) return "+5~6";
if (blockLag >= 7 && blockLag <= 9) return "+7~9";
if (blockLag >= 10 && blockLag <= 14) return "+10~14";
if (blockLag >= 15 && blockLag <= 19) return "+15~19";
if (blockLag >= 20 && blockLag <= 29) return "+20~29";
if (blockLag >= 30 && blockLag <= 39) return "+30~39";
if (blockLag >= 40) return "+40+";
return "other";
}
/**
* Analyze data and generate comprehensive statistics
* @param {Array} data - Array of order data to analyze
* @returns {Promise<Object>} Analysis results
*/
async analyzeSolvedTypeData(data) {
if (!Array.isArray(data) || data.length === 0) {
throw new Error("No valid order data found");
}
if (this.tokenInfoManager) {
await this.tokenInfoManager.initialize();
const uniqueTokens = /* @__PURE__ */ new Set();
data.forEach((order) => {
const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token;
const outputToken = this.getOutputToken(order);
if (inputToken && inputToken !== "N/A") uniqueTokens.add(inputToken);
if (outputToken && outputToken !== "N/A") uniqueTokens.add(outputToken);
});
if (uniqueTokens.size > 0) {
await this.tokenInfoManager.batchFetchTokenInfo([...uniqueTokens]);
}
}
const speedOrders = data.filter((order) => order.solvedType === "Speed");
let orderTimingData = {};
if (speedOrders.length > 0 && this.portusApiClient?.isAvailable) {
try {
const orderHashes = speedOrders.map((order) => order.orderHash).filter(Boolean);
if (orderHashes.length > 0) {
orderTimingData = await this.portusApiClient.getOrdersTimingData(orderHashes);
}
} catch (error) {
console.warn(`\u26A0\uFE0F Failed to fetch timing data, will skip time interval analysis: ${error.message}`);
}
}
const stats = {
totalOrders: data.length,
totalProfit: 0,
solvedTypeStats: /* @__PURE__ */ new Map(),
// Normal, Speed, Resolved, Other
speedIntervalStats: /* @__PURE__ */ new Map(),
// <200ms, <400ms, <600ms, <800ms, <1000ms, ≥1000ms
resolvedBlockLagStats: /* @__PURE__ */ new Map(),
// +1, +2, +3, +4, +5~6, +7~9, +10+
normalTokenPairStats: /* @__PURE__ */ new Map()
// token pair analysis for Normal orders
};
for (const order of data) {
const fillerProfitUsd = parseFloat(order.orderUsdSummary?.fillerProfitUsd || 0);
const solvedType = order.solvedType || "Other";
const blockNumber = parseInt(order.blockNumber || 0);
const pgaBlock = this.calculatePgaBlock(order);
const isPortus = (order.filler || "").toLowerCase() === this.PORTUS_ADDRESS;
stats.totalProfit += fillerProfitUsd;
if (!stats.solvedTypeStats.has(solvedType)) {
stats.solvedTypeStats.set(solvedType, {
orders: 0,
profit: 0,
portusOrders: 0,
portusProfit: 0
});
}
const solvedTypeData = stats.solvedTypeStats.get(solvedType);
solvedTypeData.orders++;
solvedTypeData.profit += fillerProfitUsd;
if (isPortus) {
solvedTypeData.portusOrders++;
solvedTypeData.portusProfit += fillerProfitUsd;
}
if (solvedType === "Speed") {
const orderHash = order.orderHash;
const orderTiming = orderTimingData[orderHash];
if (orderTiming && orderTiming.receivedAt && orderTiming.pgaBlockAt) {
try {
const timeInterval = PortusApiClient.calculateOrderTimingInterval(
orderTiming.receivedAt,
orderTiming.pgaBlockAt
);
if (timeInterval !== null) {
const rangeKey = this.getSpeedIntervalRange(timeInterval);
if (!stats.speedIntervalStats.has(rangeKey)) {
stats.speedIntervalStats.set(rangeKey, {
orders: 0,
profit: 0,
portusOrders: 0,
portusProfit: 0
});
}
const speedData = stats.speedIntervalStats.get(rangeKey);
speedData.orders++;
speedData.profit += fillerProfitUsd;
if (isPortus) {
speedData.portusOrders++;
speedData.portusProfit += fillerProfitUsd;
}
}
} catch (error) {
console.warn(`\u26A0\uFE0F Failed to calculate time interval for order ${orderHash}: ${error.message}`);
}
}
}
if (solvedType === "Resolved" && blockNumber > pgaBlock) {
const blockLag = blockNumber - pgaBlock;
const rangeKey = this.getResolvedBlockLagRange(blockLag);
if (!stats.resolvedBlockLagStats.has(rangeKey)) {
stats.resolvedBlockLagStats.set(rangeKey, {
orders: 0,
profit: 0,
portusOrders: 0,
portusProfit: 0
});
}
const resolvedData = stats.resolvedBlockLagStats.get(rangeKey);
resolvedData.orders++;
resolvedData.profit += fillerProfitUsd;
if (isPortus) {
resolvedData.portusOrders++;
resolvedData.portusProfit += fillerProfitUsd;
}
}
if (solvedType === "Normal") {
const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token || "N/A";
const outputToken = this.getOutputToken(order);
if (inputToken !== "N/A" && outputToken !== "N/A") {
let inputSymbol, outputSymbol;
if (this.tokenInfoManager) {
const inputTokenInfo = this.tokenInfoManager.getTokenInfo(inputToken);
const outputTokenInfo = this.tokenInfoManager.getTokenInfo(outputToken);
inputSymbol = this.truncateTokenSymbol(inputTokenInfo?.symbol || "UNKNOWN");
outputSymbol = this.truncateTokenSymbol(outputTokenInfo?.symbol || "UNKNOWN");
} else {
inputSymbol = this.truncateTokenSymbol(`${inputToken.slice(0, 6)}...${inputToken.slice(-4)}`);
outputSymbol = this.truncateTokenSymbol(`${outputToken.slice(0, 6)}...${outputToken.slice(-4)}`);
}
const pairKey = `${inputSymbol}-${outputSymbol}`;
if (!stats.normalTokenPairStats.has(pairKey)) {
stats.normalTokenPairStats.set(pairKey, {
orders: 0,
profit: 0,
portusOrders: 0,
portusProfit: 0
});
}
const pairData = stats.normalTokenPairStats.get(pairKey);
pairData.orders++;
pairData.profit += fillerProfitUsd;
if (isPortus) {
pairData.portusOrders++;
pairData.portusProfit += fillerProfitUsd;
}
}
}
}
if (this.tokenInfoManager) {
await this.tokenInfoManager.saveCache();
}
return stats;
}
/**
* Generate Slack message format for SolvedType report
* @param {string} startDate - Start date
* @param {string} endDate - End date (optional, if null defaults to startDate)
* @param {Object} stats - Statistics data
* @returns {Object} Slack message object
*/
generateSlackMessage(startDate, endDate = null, stats) {
if (!endDate) {
endDate = startDate;
}
const isSingleDate = startDate === endDate;
let headerText, contextText;
if (isSingleDate) {
const dateStr = new Date(startDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long"
});
headerText = "\u{1F4CA} SolvedType Analysis Report";
contextText = `*${dateStr}* | Portus team analysis system`;
} else {
const startDateStr = new Date(startDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
const endDateStr = new Date(endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
headerText = "\u{1F4CA} SolvedType Analysis Report";
contextText = `*${startDateStr} - ${endDateStr}* | Portus team analysis system`;
}
const blocks = [
{
type: "header",
text: {
type: "plain_text",
text: headerText
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: contextText
}
]
},
{
type: "divider"
}
];
const solvedTypeOrder = ["Normal", "Speed", "Resolved", "Other"];
let overviewTable = `\u{1F50E} *OVERVIEW*
\u2022 Total Orders: *${FormattingUtils.formatNumber(
stats.totalOrders,
0
)}* | Total Profit: *${FormattingUtils.formatUSD(stats.totalProfit)}*
`;
overviewTable += `\`\`\`
`;
overviewTable += `\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
`;
overviewTable += `\u2502 Type \u2502 Orders \u2502 Orders % \u2502 Profit \u2502 Profit % \u2502
`;
overviewTable += `\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524
`;
for (const solvedType of solvedTypeOrder) {
const data = stats.solvedTypeStats.get(solvedType);
if (data) {
const orderPercentage = stats.totalOrders > 0 ? data.orders / stats.totalOrders * 100 : 0;
const profitPercentage = stats.totalProfit > 0 ? data.profit / stats.totalProfit * 100 : 0;
const typeName = solvedType.padEnd(8, " ");
const ordersCount = FormattingUtils.formatNumber(data.orders, 0).padEnd(8, " ");
const ordersPercent = FormattingUtils.formatPercentage(orderPercentage / 100).padEnd(8, " ");
const profitAmount = FormattingUtils.formatUSD(data.profit).padEnd(8, " ");
const profitPercent = FormattingUtils.formatPercentage(profitPercentage / 100).padEnd(8, " ");
overviewTable += `\u2502 ${typeName} \u2502 ${ordersCount} \u2502 ${ordersPercent} \u2502 ${profitAmount} \u2502 ${profitPercent} \u2502
`;
} else {
const typeName = solvedType.padEnd(9, " ");
overviewTable += `\u2502 ${typeName} \u2502 0 \u2502 0% \u2502 $0 \u2502 0% \u2502
`;
}
}
overviewTable += `\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
`;
overviewTable += `\`\`\``;
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: overviewTable
}
});
blocks.push({ type: "divider" });
this.addNormalOrdersSection(blocks, stats);
this.addSpeedOrdersSection(blocks, stats);
this.addResolvedOrdersSection(blocks, stats);
return { blocks };
}
/**
* Add Normal Orders section
* @param {Array} blocks - Slack blocks array
* @param {Object} stats - Statistics data
*/
addNormalOrdersSection(blocks, stats) {
const normalData = stats.solvedTypeStats.get("Normal");
if (!normalData || normalData.orders === 0) return;
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: "\u{1F699} *NORMAL ORDER*"
}
});
let normalText = `\u27A4 Total: *${FormattingUtils.formatNumber(
normalData.orders,
0
)}* orders | Profit *\`${FormattingUtils.formatUSD(normalData.profit)}\`*
`;
normalText += `\u27A4 Portus: *${FormattingUtils.formatNumber(
normalData.portusOrders,
0
)}* orders | Profit *\`${FormattingUtils.formatUSD(normalData.portusProfit)}\`* | *${FormattingUtils.formatPercentage(
normalData.portusProfit / normalData.profit
)}*
`;
const sortedPairs = Array.from(stats.normalTokenPairStats.entries()).sort((a, b) => b[1].profit - a[1].profit).slice(0, 5);
normalText += "\u27A4 Market Top Token Pairs:\n";
for (let i = 0; i < sortedPairs.length; i++) {
const [pairSymbol, pairData] = sortedPairs[i];
const portusRatio = pairData.profit > 0 ? pairData.portusProfit / pairData.profit * 100 : 0;
normalText += `\u251C *${i + 1}. \`${pairSymbol}\`*: *${FormattingUtils.formatNumber(
pairData.orders,
0
)}* orders | Profit: *\`${FormattingUtils.formatUSD(pairData.profit)}\`* | Portus: *${FormattingUtils.formatUSD(
pairData.portusProfit
)}* *\`${FormattingUtils.formatPercentage(portusRatio / 100)}\`*
`;
}
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: normalText
}
});
blocks.push({ type: "divider" });
}
/**
* Add Speed Orders section
* @param {Array} blocks - Slack blocks array
* @param {Object} stats - Statistics data
*/
addSpeedOrdersSection(blocks, stats) {
const speedData = stats.solvedTypeStats.get("Speed");
if (!speedData || speedData.orders === 0) return;
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: "\u{1F3CE}\uFE0F *SPEED ORDER*"
}
});
let speedText = `\u27A4 Total: *${FormattingUtils.formatNumber(
speedData.orders,
0
)}* orders | Profit *\`${FormattingUtils.formatUSD(speedData.profit)}\`*
`;
speedText += `\u27A4 Portus: *${FormattingUtils.formatNumber(
speedData.portusOrders,
0
)}* orders | Profit *\`${FormattingUtils.formatUSD(speedData.portusProfit)}\`* | *${FormattingUtils.formatPercentage(
speedData.portusProfit / speedData.profit
)}*
`;
speedText += "\u27A4 Interval Analysis:\n";
const speedRangeOrder = ["<0ms", "<50ms", "<100ms", "<200ms", "<400ms", "\