realtime tracker
This commit is contained in:
@@ -18,39 +18,36 @@ CREATE TABLE IF NOT EXISTS wallets (
|
|||||||
INDEX idx_trade_count (trade_count)
|
INDEX idx_trade_count (trade_count)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracked wallet addresses from Hyperliquid trades';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracked wallet addresses from Hyperliquid trades';
|
||||||
|
|
||||||
-- Wallet PnL snapshots table (optional - for historical tracking)
|
-- Note: PnL is now calculated from trades table, so wallet_pnl_snapshots table is no longer used
|
||||||
CREATE TABLE IF NOT EXISTS wallet_pnl_snapshots (
|
|
||||||
|
-- Trades table to store individual trades for each wallet
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
wallet_id INT NOT NULL,
|
wallet_id INT NOT NULL,
|
||||||
wallet_address VARCHAR(66) NOT NULL,
|
wallet_address VARCHAR(66) NOT NULL,
|
||||||
pnl DECIMAL(30, 8) COMMENT 'Profit and Loss value',
|
entry_hash VARCHAR(66) NOT NULL UNIQUE COMMENT 'Unique identifier for the trade entry (transaction hash)',
|
||||||
account_value DECIMAL(30, 8) COMMENT 'Total account value',
|
entry_date TIMESTAMP NULL COMMENT 'When the position was opened',
|
||||||
unrealized_pnl DECIMAL(30, 8) COMMENT 'Unrealized PnL',
|
close_date TIMESTAMP NULL COMMENT 'When the position was closed (NULL if still open)',
|
||||||
snapshot_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
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,
|
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,
|
FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE,
|
||||||
INDEX idx_wallet_id (wallet_id),
|
INDEX idx_wallet_id (wallet_id),
|
||||||
INDEX idx_wallet_address (wallet_address),
|
INDEX idx_wallet_address (wallet_address),
|
||||||
INDEX idx_snapshot_at (snapshot_at)
|
INDEX idx_entry_hash (entry_hash),
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Historical PnL snapshots for tracked wallets';
|
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
|
-- Migration: Add direction column if it doesn't exist
|
||||||
CREATE OR REPLACE VIEW wallets_with_latest_pnl AS
|
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;
|
||||||
SELECT
|
ALTER TABLE trades ADD INDEX IF NOT EXISTS idx_direction (direction);
|
||||||
w.id,
|
|
||||||
w.address,
|
-- Note: PnL is now calculated on-demand from trades table, so the view is no longer needed
|
||||||
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;
|
|
||||||
|
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -665,6 +665,7 @@
|
|||||||
"integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==",
|
"integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1875,6 +1876,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
443
src/database.ts
443
src/database.ts
@@ -6,57 +6,94 @@ import * as path from 'path';
|
|||||||
const envLocalPath = path.join(process.cwd(), '.env.local');
|
const envLocalPath = path.join(process.cwd(), '.env.local');
|
||||||
const envPath = path.join(process.cwd(), '.env');
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
dotenv.config({ path: envLocalPath });
|
const envLocalResult = dotenv.config({ path: envLocalPath });
|
||||||
dotenv.config({ path: envPath }); // .env.local values will override .env
|
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';
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
const dbConfig: mysql.PoolOptions = {
|
// Helper to get env var with fallback, handling empty strings
|
||||||
host: process.env.DB_HOST || 'localhost',
|
const getEnvVar = (key: string, defaultValue: string): string => {
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
const value = process.env[key];
|
||||||
user: process.env.DB_USER || 'root',
|
if (!value || !value.trim()) {
|
||||||
password: process.env.DB_PASSWORD || '',
|
return defaultValue;
|
||||||
database: process.env.DB_NAME || 'hyperliquid_tracker',
|
}
|
||||||
waitForConnections: true,
|
return value.trim();
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
// Connection timeout settings
|
|
||||||
connectTimeout: 60000, // 60 seconds
|
|
||||||
// Enable keep-alive to prevent connection from being closed
|
|
||||||
enableKeepAlive: true,
|
|
||||||
keepAliveInitialDelay: 0,
|
|
||||||
// For remote connections, often need SSL or allow insecure connections
|
|
||||||
...(process.env.DB_SSL === 'true' ? {
|
|
||||||
ssl: {
|
|
||||||
rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false'
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
// Additional connection options
|
|
||||||
timezone: 'Z', // Use UTC
|
|
||||||
charset: 'utf8mb4',
|
|
||||||
// Multiple statements can help with some connection issues
|
|
||||||
multipleStatements: false,
|
|
||||||
// Flags for better compatibility
|
|
||||||
flags: [
|
|
||||||
'-FOUND_ROWS',
|
|
||||||
'-IGNORE_SPACE',
|
|
||||||
'-LONG_PASSWORD',
|
|
||||||
'-LONG_FLAG',
|
|
||||||
'-TRANSACTIONS',
|
|
||||||
'-PROTOCOL_41',
|
|
||||||
'-SECURE_CONNECTION',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
// Connection timeout settings
|
||||||
|
connectTimeout: 60000, // 60 seconds
|
||||||
|
// Enable keep-alive to prevent connection from being closed
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 0,
|
||||||
|
// For remote connections, often need SSL or allow insecure connections
|
||||||
|
...(process.env.DB_SSL === 'true' ? {
|
||||||
|
ssl: {
|
||||||
|
rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false'
|
||||||
|
}
|
||||||
|
} : {}),
|
||||||
|
// Additional connection options
|
||||||
|
timezone: 'Z', // Use UTC
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
// Multiple statements can help with some connection issues
|
||||||
|
multipleStatements: false,
|
||||||
|
// Flags for better compatibility
|
||||||
|
flags: [
|
||||||
|
'-FOUND_ROWS',
|
||||||
|
'-IGNORE_SPACE',
|
||||||
|
'-LONG_PASSWORD',
|
||||||
|
'-LONG_FLAG',
|
||||||
|
'-TRANSACTIONS',
|
||||||
|
'-PROTOCOL_41',
|
||||||
|
'-SECURE_CONNECTION',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[DB] Database config loaded:', {
|
console.log('[DB] Database config loaded:', {
|
||||||
host: dbConfig.host,
|
host: dbConfig.host,
|
||||||
port: dbConfig.port,
|
port: dbConfig.port,
|
||||||
database: dbConfig.database,
|
database: dbConfig.database,
|
||||||
user: dbConfig.user,
|
user: dbConfig.user,
|
||||||
passwordSet: !!dbConfig.password,
|
passwordSet: !!dbConfig.password,
|
||||||
|
passwordLength: dbConfig.password ? dbConfig.password.length : 0,
|
||||||
ssl: dbConfig.ssl ? 'enabled' : 'disabled',
|
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
|
// Create connection pool
|
||||||
let pool: mysql.Pool | null = null;
|
let pool: mysql.Pool | null = null;
|
||||||
@@ -65,15 +102,19 @@ let pool: mysql.Pool | null = null;
|
|||||||
* Initialize database connection pool
|
* Initialize database connection pool
|
||||||
*/
|
*/
|
||||||
export function initDatabase(): mysql.Pool {
|
export function initDatabase(): mysql.Pool {
|
||||||
|
console.log('getting db config');
|
||||||
|
|
||||||
|
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
pool = mysql.createPool(dbConfig);
|
pool = mysql.createPool({
|
||||||
console.log('[DB] ✅ Database connection pool created');
|
|
||||||
console.log('[DB] Config:', {
|
|
||||||
host: dbConfig.host,
|
host: dbConfig.host,
|
||||||
port: dbConfig.port,
|
port: dbConfig.port,
|
||||||
database: dbConfig.database,
|
|
||||||
user: dbConfig.user,
|
user: dbConfig.user,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database,
|
||||||
|
connectTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
console.log('[DB] ✅ Database connection pool created');
|
||||||
|
|
||||||
// Handle connection events
|
// Handle connection events
|
||||||
pool.on('connection', (connection: any) => {
|
pool.on('connection', (connection: any) => {
|
||||||
@@ -110,15 +151,18 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
// Create a direct connection for testing (not using pool)
|
// Create a direct connection for testing (not using pool)
|
||||||
const directConnection = await mysql.createConnection({
|
const directConnection = await mysql.createConnection({
|
||||||
...dbConfig,
|
host: dbConfig.host,
|
||||||
// Reduce timeout for faster failure detection
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.user,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database,
|
||||||
connectTimeout: 10000,
|
connectTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DB] 🔌 Direct connection established');
|
console.log('[DB] 🔌 Direct connection established');
|
||||||
|
|
||||||
// Immediately test the connection
|
// 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);
|
console.log('[DB] ✅ Test query successful:', rows);
|
||||||
|
|
||||||
await directConnection.ping();
|
await directConnection.ping();
|
||||||
@@ -150,6 +194,7 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (poolError: any) {
|
} catch (poolError: any) {
|
||||||
|
console.error('[DB] Pool connection error:', poolError);
|
||||||
console.error(`[DB] ❌ Pool connection failed (attempt ${attempt}/${maxRetries}):`, poolError.message);
|
console.error(`[DB] ❌ Pool connection failed (attempt ${attempt}/${maxRetries}):`, poolError.message);
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
@@ -186,8 +231,8 @@ export async function testConnection(maxRetries: number = 3): Promise<boolean> {
|
|||||||
connectTimeout: 10000,
|
connectTimeout: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const testConnection = await mysql.createConnection(testConfig);
|
const testConnection = await mysql.createConnection(dbConfig);
|
||||||
const [testRows] = await testConnection.query('SELECT 1 as test, USER() as current_user');
|
const [testRows] = await testConnection.query('SELECT 1 as test, USER() as `current_user`');
|
||||||
console.log('[DB] ✅ Connection without database works:', testRows);
|
console.log('[DB] ✅ Connection without database works:', testRows);
|
||||||
console.log('[DB] 💡 Issue may be with database name or permissions on that database');
|
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> {
|
export async function addWalletToDB(wallet: string): Promise<boolean> {
|
||||||
try {
|
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 normalizedWallet = wallet.toLowerCase();
|
||||||
const connectionPool = getPool();
|
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
|
* Get wallet by address
|
||||||
*/
|
*/
|
||||||
@@ -355,6 +420,12 @@ export async function migrateWalletsFromJSON(wallets: string[]): Promise<number>
|
|||||||
const connectionPool = getPool();
|
const connectionPool = getPool();
|
||||||
|
|
||||||
for (const wallet of wallets) {
|
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();
|
const normalizedWallet = wallet.toLowerCase();
|
||||||
try {
|
try {
|
||||||
await connectionPool.execute(
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
1005
src/index.ts
1005
src/index.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user