realtime tracker

This commit is contained in:
React User
2026-01-17 23:41:47 +00:00
parent 7c6219e8ce
commit 62dad43e26
4 changed files with 1261 additions and 240 deletions

View File

@@ -18,39 +18,36 @@ CREATE TABLE IF NOT EXISTS wallets (
INDEX idx_trade_count (trade_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracked wallet addresses from Hyperliquid trades';
-- Wallet PnL snapshots table (optional - for historical tracking)
CREATE TABLE IF NOT EXISTS wallet_pnl_snapshots (
-- Note: PnL is now calculated from trades table, so wallet_pnl_snapshots table is no longer used
-- Trades table to store individual trades for each wallet
CREATE TABLE IF NOT EXISTS trades (
id INT AUTO_INCREMENT PRIMARY KEY,
wallet_id INT NOT NULL,
wallet_address VARCHAR(66) NOT NULL,
pnl DECIMAL(30, 8) COMMENT 'Profit and Loss value',
account_value DECIMAL(30, 8) COMMENT 'Total account value',
unrealized_pnl DECIMAL(30, 8) COMMENT 'Unrealized PnL',
snapshot_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
entry_hash VARCHAR(66) NOT NULL UNIQUE COMMENT 'Unique identifier for the trade entry (transaction hash)',
entry_date TIMESTAMP NULL COMMENT 'When the position was opened',
close_date TIMESTAMP NULL COMMENT 'When the position was closed (NULL if still open)',
coin VARCHAR(20) NOT NULL COMMENT 'Trading pair/coin symbol',
amount DECIMAL(30, 8) NOT NULL COMMENT 'Trade size/amount',
entry_price DECIMAL(30, 8) NOT NULL COMMENT 'Price when position was opened',
close_price DECIMAL(30, 8) NULL COMMENT 'Price when position was closed (NULL if still open)',
direction ENUM('buy', 'sell') NOT NULL DEFAULT 'buy' COMMENT 'Trade direction: buy or sell',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE,
INDEX idx_wallet_id (wallet_id),
INDEX idx_wallet_address (wallet_address),
INDEX idx_snapshot_at (snapshot_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Historical PnL snapshots for tracked wallets';
INDEX idx_entry_hash (entry_hash),
INDEX idx_coin (coin),
INDEX idx_entry_date (entry_date),
INDEX idx_close_date (close_date),
INDEX idx_direction (direction)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Individual trades for tracked wallets';
-- View for wallets with latest PnL
CREATE OR REPLACE VIEW wallets_with_latest_pnl AS
SELECT
w.id,
w.address,
w.first_seen_at,
w.last_seen_at,
w.trade_count,
wps.pnl,
wps.account_value,
wps.unrealized_pnl,
wps.snapshot_at as last_pnl_snapshot
FROM wallets w
LEFT JOIN wallet_pnl_snapshots wps ON w.id = wps.wallet_id
LEFT JOIN (
SELECT wallet_id, MAX(snapshot_at) as max_snapshot
FROM wallet_pnl_snapshots
GROUP BY wallet_id
) latest ON w.id = latest.wallet_id AND wps.snapshot_at = latest.max_snapshot;
-- Migration: Add direction column if it doesn't exist
ALTER TABLE trades ADD COLUMN IF NOT EXISTS direction ENUM('buy', 'sell') NOT NULL DEFAULT 'buy' COMMENT 'Trade direction: buy or sell' AFTER close_price;
ALTER TABLE trades ADD INDEX IF NOT EXISTS idx_direction (direction);
-- Note: PnL is now calculated on-demand from trades table, so the view is no longer needed

2
package-lock.json generated
View File

@@ -665,6 +665,7 @@
"integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1875,6 +1876,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -6,18 +6,45 @@ import * as path from 'path';
const envLocalPath = path.join(process.cwd(), '.env.local');
const envPath = path.join(process.cwd(), '.env');
dotenv.config({ path: envLocalPath });
dotenv.config({ path: envPath }); // .env.local values will override .env
const envLocalResult = dotenv.config({ path: envLocalPath });
if (envLocalResult.error) {
if (envLocalResult.error.message && !envLocalResult.error.message.includes('ENOENT')) {
console.warn('[ENV] Warning loading .env.local:', envLocalResult.error.message);
}
} else {
console.log('[ENV] ✅ Loaded .env.local from:', envLocalPath);
}
const envResult = dotenv.config({ path: envPath });
if (envResult.error) {
if (envResult.error.message && !envResult.error.message.includes('ENOENT')) {
console.warn('[ENV] Warning loading .env:', envResult.error.message);
}
} else {
console.log('[ENV] ✅ Loaded .env from:', envPath);
}
import mysql from 'mysql2/promise';
// Database configuration
const dbConfig: mysql.PoolOptions = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hyperliquid_tracker',
// Helper to get env var with fallback, handling empty strings
const getEnvVar = (key: string, defaultValue: string): string => {
const value = process.env[key];
if (!value || !value.trim()) {
return defaultValue;
}
return value.trim();
};
let dbConfig: mysql.PoolOptions = getDBConfig();
function getDBConfig(): mysql.PoolOptions {
return {
host: getEnvVar('DB_HOST', 'localhost'),
port: parseInt(getEnvVar('DB_PORT', '3306')),
user: getEnvVar('DB_USER', 'hypr'),
password: process.env.DB_PASSWORD,
database: getEnvVar('DB_NAME', 'hyperliquid_tracker'),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
@@ -47,7 +74,8 @@ const dbConfig: mysql.PoolOptions = {
'-PROTOCOL_41',
'-SECURE_CONNECTION',
],
};
};
}
console.log('[DB] Database config loaded:', {
host: dbConfig.host,
@@ -55,8 +83,17 @@ console.log('[DB] Database config loaded:', {
database: dbConfig.database,
user: dbConfig.user,
passwordSet: !!dbConfig.password,
passwordLength: dbConfig.password ? dbConfig.password.length : 0,
ssl: dbConfig.ssl ? 'enabled' : 'disabled',
});
console.log('[DB] Raw environment variables:', {
DB_HOST: process.env.DB_HOST ? `"${process.env.DB_HOST}"` : '(not set)',
DB_PORT: process.env.DB_PORT ? `"${process.env.DB_PORT}"` : '(not set)',
DB_USER: process.env.DB_USER !== undefined ? `"${process.env.DB_USER}" (length: ${process.env.DB_USER.length})` : '(not set)',
DB_NAME: process.env.DB_NAME ? `"${process.env.DB_NAME}"` : '(not set)',
DB_PASSWORD: process.env.DB_PASSWORD ? `***${process.env.DB_PASSWORD.length} chars***` : '(not set)',
});
console.log('[DB] getEnvVar result for DB_USER:', getEnvVar('DB_USER', 'hypr'));
// Create connection pool
let pool: mysql.Pool | null = null;
@@ -65,15 +102,19 @@ let pool: mysql.Pool | null = null;
* Initialize database connection pool
*/
export function initDatabase(): mysql.Pool {
console.log('getting db config');
if (!pool) {
pool = mysql.createPool(dbConfig);
console.log('[DB] ✅ Database connection pool created');
console.log('[DB] Config:', {
pool = mysql.createPool({
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database,
user: dbConfig.user,
password: dbConfig.password,
database: dbConfig.database,
connectTimeout: 10000,
});
console.log('[DB] ✅ Database connection pool created');
// Handle connection events
pool.on('connection', (connection: any) => {
@@ -110,15 +151,18 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
try {
// Create a direct connection for testing (not using pool)
const directConnection = await mysql.createConnection({
...dbConfig,
// Reduce timeout for faster failure detection
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.user,
password: dbConfig.password,
database: dbConfig.database,
connectTimeout: 10000,
});
console.log('[DB] 🔌 Direct connection established');
// Immediately test the connection
const [rows] = await directConnection.query('SELECT 1 as test, DATABASE() as current_db, USER() as current_user, VERSION() as version');
const [rows] = await directConnection.query('SELECT 1 as test, DATABASE() as current_db, USER() as `current_user`, VERSION() as version');
console.log('[DB] ✅ Test query successful:', rows);
await directConnection.ping();
@@ -150,6 +194,7 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
return true;
} catch (poolError: any) {
console.error('[DB] Pool connection error:', poolError);
console.error(`[DB] ❌ Pool connection failed (attempt ${attempt}/${maxRetries}):`, poolError.message);
if (attempt < maxRetries) {
@@ -186,8 +231,8 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
connectTimeout: 10000,
};
const testConnection = await mysql.createConnection(testConfig);
const [testRows] = await testConnection.query('SELECT 1 as test, USER() as current_user');
const testConnection = await mysql.createConnection(dbConfig);
const [testRows] = await testConnection.query('SELECT 1 as test, USER() as `current_user`');
console.log('[DB] ✅ Connection without database works:', testRows);
console.log('[DB] 💡 Issue may be with database name or permissions on that database');
@@ -239,6 +284,12 @@ export async function loadWalletsFromDB(): Promise<string[]> {
*/
export async function addWalletToDB(wallet: string): Promise<boolean> {
try {
// Validate wallet address length (Ethereum addresses are exactly 42 characters: 0x + 40 hex chars)
if (!wallet || wallet.length !== 42 || !wallet.startsWith('0x')) {
console.log(`[DB] ⚠️ Invalid wallet format/length (expected 42 chars), skipping:`, wallet?.substring(0, 50));
return false;
}
const normalizedWallet = wallet.toLowerCase();
const connectionPool = getPool();
@@ -258,6 +309,20 @@ export async function addWalletToDB(wallet: string): Promise<boolean> {
}
}
/**
* Delete wallet from database (e.g. when invalid length like tx hash)
*/
export async function deleteWalletFromDB(wallet: string): Promise<boolean> {
try {
const connectionPool = getPool();
await connectionPool.execute('DELETE FROM wallets WHERE address = ?', [wallet]);
return true;
} catch (error) {
console.error('[DB] Error deleting wallet:', error);
return false;
}
}
/**
* Get wallet by address
*/
@@ -355,6 +420,12 @@ export async function migrateWalletsFromJSON(wallets: string[]): Promise<number>
const connectionPool = getPool();
for (const wallet of wallets) {
// Validate wallet address length (Ethereum addresses are exactly 42 characters)
if (!wallet || wallet.length !== 42 || !wallet.startsWith('0x')) {
console.log(`[DB] ⚠️ Skipping invalid wallet during migration (length: ${wallet?.length}):`, wallet?.substring(0, 50));
continue;
}
const normalizedWallet = wallet.toLowerCase();
try {
await connectionPool.execute(
@@ -377,3 +448,283 @@ export async function migrateWalletsFromJSON(wallets: string[]): Promise<number>
}
}
/**
* Clean up invalid wallet entries (addresses that are not exactly 42 characters)
* This removes transaction hashes and other invalid data that may have been stored
*/
export async function cleanupInvalidWallets(): Promise<number> {
try {
const connectionPool = getPool();
// Delete wallets that are not exactly 42 characters (Ethereum addresses are 0x + 40 hex = 42)
const [result] = await connectionPool.execute<mysql.ResultSetHeader>(
`DELETE FROM wallets WHERE LENGTH(address) != 42 OR address NOT LIKE '0x%'`
);
const deletedCount = result.affectedRows || 0;
if (deletedCount > 0) {
console.log(`[DB] 🧹 Cleaned up ${deletedCount} invalid wallet entries (non-42-char addresses)`);
} else {
console.log(`[DB] ✅ No invalid wallet entries found`);
}
return deletedCount;
} catch (error) {
console.error('[DB] Error cleaning up invalid wallets:', error);
return 0;
}
}
/**
* Save a trade to the database
* Uses entry_hash as unique identifier - updates existing trade if entry_hash exists and close info is provided
*/
export async function saveTradeToDB(
walletAddress: string,
entryHash: string,
coin: string,
amount: string | number,
entryPrice: string | number,
entryDate?: Date | string | null,
closePrice?: string | number | null,
closeDate?: Date | string | null,
direction: 'buy' | 'sell' = 'buy'
): Promise<boolean> {
try {
const normalizedWallet = walletAddress.toLowerCase();
const normalizedEntryHash = entryHash.toLowerCase();
const connectionPool = getPool();
// Get wallet ID
const wallet = await getWalletFromDB(normalizedWallet);
if (!wallet) {
console.error(`[DB] Wallet not found for trade: ${normalizedWallet}`);
return false;
}
// If we have close information, check if it's a zero-PnL trade
if (closePrice !== null && closePrice !== undefined && closeDate) {
const entryPriceNum = parseFloat(String(entryPrice)) || 0;
const closePriceNum = parseFloat(String(closePrice)) || 0;
// If entry_price equals close_price (zero-PnL), delete the trade instead
if (Math.abs(entryPriceNum - closePriceNum) < 0.00000001) {
console.log(`[DB] Deleting zero-PnL trade: entry_hash ${normalizedEntryHash.substring(0, 16)}... (entry: ${entryPriceNum}, close: ${closePriceNum})`);
await connectionPool.execute(
'DELETE FROM trades WHERE entry_hash = ?',
[normalizedEntryHash]
);
return true;
}
// Update existing trade with close information
await connectionPool.execute(
`UPDATE trades
SET close_price = ?, close_date = ?, updated_at = CURRENT_TIMESTAMP
WHERE entry_hash = ?`,
[closePrice, closeDate, normalizedEntryHash]
);
} else {
// Insert new trade or update if entry_hash exists (for open trades)
await connectionPool.execute(
`INSERT INTO trades (wallet_id, wallet_address, entry_hash, coin, amount, entry_price, entry_date, close_price, close_date, direction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
entry_price = VALUES(entry_price),
entry_date = VALUES(entry_date),
direction = VALUES(direction),
updated_at = CURRENT_TIMESTAMP`,
[
wallet.id,
normalizedWallet,
normalizedEntryHash,
coin,
amount,
entryPrice,
entryDate || null,
closePrice || null,
closeDate || null,
direction,
]
);
}
return true;
} catch (error) {
console.error('[DB] Error saving trade:', error);
return false;
}
}
/**
* Calculate PnL from stored trades for a wallet
* Returns: { realizedPnl, unrealizedPnl, totalPnl, closedTradesCount, openTradesCount }
*/
export async function calculatePnLFromTrades(walletAddress: string): Promise<{
realizedPnl: string;
unrealizedPnl: string;
totalPnl: string;
closedTradesCount: number;
openTradesCount: number;
}> {
try {
const trades = await getTradesForWallet(walletAddress);
let realizedPnl = 0;
let unrealizedPnl = 0;
let closedTradesCount = 0;
let openTradesCount = 0;
for (const trade of trades) {
const amount = parseFloat(trade.amount) || 0;
const entryPrice = parseFloat(trade.entry_price) || 0;
if (trade.close_date && trade.close_price) {
// Closed trade - calculate realized PnL
const closePrice = parseFloat(trade.close_price) || 0;
const tradePnl = (closePrice - entryPrice) * amount;
realizedPnl += tradePnl;
closedTradesCount++;
} else {
// Open trade - we'll need current price for unrealized PnL
// For now, we'll set it to 0 and it can be updated with current prices if needed
openTradesCount++;
// Note: Unrealized PnL requires current market price, which we'd need to fetch
// For now, we'll leave it at 0 or calculate it separately if current prices are available
}
}
return {
realizedPnl: realizedPnl.toFixed(8),
unrealizedPnl: unrealizedPnl.toFixed(8),
totalPnl: (realizedPnl + unrealizedPnl).toFixed(8),
closedTradesCount,
openTradesCount,
};
} catch (error) {
console.error('[DB] Error calculating PnL from trades:', error);
return {
realizedPnl: '0',
unrealizedPnl: '0',
totalPnl: '0',
closedTradesCount: 0,
openTradesCount: 0,
};
}
}
/**
* Get all trades for a wallet
*/
export async function getTradesForWallet(walletAddress: string): Promise<any[]> {
try {
const normalizedWallet = walletAddress.toLowerCase();
const connectionPool = getPool();
const [rows] = await connectionPool.execute<mysql.RowDataPacket[]>(
`SELECT
id,
wallet_address,
entry_hash,
entry_date,
close_date,
coin,
amount,
entry_price,
close_price,
direction,
created_at,
updated_at
FROM trades
WHERE wallet_address = ?
ORDER BY entry_date DESC, created_at DESC`,
[normalizedWallet]
);
return rows as any[];
} catch (error) {
console.error('[DB] Error getting trades for wallet:', error);
return [];
}
}
/**
* Get all open trades (where close_date IS NULL)
*/
export async function getOpenTrades(): Promise<any[]> {
try {
const connectionPool = getPool();
const [rows] = await connectionPool.execute<mysql.RowDataPacket[]>(
`SELECT
id,
wallet_address,
entry_hash,
entry_date,
coin,
amount,
entry_price
FROM trades
WHERE close_date IS NULL
ORDER BY entry_date DESC`
);
return rows as any[];
} catch (error) {
console.error('[DB] Error getting open trades:', error);
return [];
}
}
/**
* Update trade close information by entry_hash
* If entry_price equals close_price (zero-PnL), deletes the trade instead
*/
export async function updateTradeClose(
entryHash: string,
closePrice: string | number,
closeDate: Date | string
): Promise<boolean> {
try {
const normalizedEntryHash = entryHash.toLowerCase();
const connectionPool = getPool();
// First, get the trade to check entry_price
const [tradeRows] = await connectionPool.execute<mysql.RowDataPacket[]>(
'SELECT entry_price FROM trades WHERE entry_hash = ?',
[normalizedEntryHash]
);
if (tradeRows.length === 0) {
return false; // Trade not found
}
const entryPrice = parseFloat(tradeRows[0].entry_price) || 0;
const closePriceNum = parseFloat(String(closePrice)) || 0;
// If entry_price equals close_price (zero-PnL), delete the trade instead
if (Math.abs(entryPrice - closePriceNum) < 0.00000001) {
console.log(`[TRADES] Deleting zero-PnL trade: entry_hash ${normalizedEntryHash.substring(0, 16)}... (entry: ${entryPrice}, close: ${closePriceNum})`);
await connectionPool.execute(
'DELETE FROM trades WHERE entry_hash = ?',
[normalizedEntryHash]
);
return true;
}
// Otherwise, update with close information
const [result] = await connectionPool.execute<mysql.ResultSetHeader>(
`UPDATE trades
SET close_price = ?, close_date = ?, updated_at = CURRENT_TIMESTAMP
WHERE entry_hash = ? AND close_date IS NULL`,
[closePrice, closeDate, normalizedEntryHash]
);
return result.affectedRows > 0;
} catch (error) {
console.error('[DB] Error updating trade close:', error);
return false;
}
}

File diff suppressed because it is too large Load Diff