Last active
January 21, 2026 20:42
-
-
Save Cahl-Dee/3ea7ad93ddb6f29e3ce6db08bc4b6018 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Grid Trading API - Consumption Balance Maintainer | |
| * | |
| * This script monitors your consumption balance and automatically purchases units | |
| * when the balance drops below the minimum threshold (default: 1 unit). | |
| * | |
| * Prerequisites: | |
| * - Node.js 18+ with ES modules support | |
| * - Environment variables configured in .env file | |
| * | |
| * Environment Configuration: | |
| * The script supports multiple environments (dev, demo, prod, etc.). | |
| * For each environment, use the following naming pattern: | |
| * | |
| * Required: | |
| * - GRID_{ENV}_TRADING_PRIVATE_KEY: Base64-encoded PKCS#8 Ed25519 private key | |
| * - GRID_{ENV}_TRADING_PUBLIC_KEY: Base64-encoded SPKI Ed25519 public key | |
| * - GRID_{ENV}_TRADING_BASE_URL: Trading API base URL | |
| * - GRID_{ENV}_{INSTRUMENT}_MARKET_ID: Market ID for the instrument (e.g., market_b310e860-97cd-45eb-bdc3-5be0b79295d0) | |
| * - GRID_{ENV}_{INSTRUMENT}_INSTRUMENT_ID: Instrument ID (e.g., instrument_e0b04b8a-ce1f-4ccc-a4ca-2a47825d69b0) | |
| * | |
| * Optional (environment-specific): | |
| * - GRID_{ENV}_TRADING_FINGERPRINT: Optional fingerprint | |
| * - GRID_{ENV}_MIN_BALANCE: Minimum units to maintain | |
| * - GRID_{ENV}_BUY_QUANTITY: Units to buy when replenishing | |
| * - GRID_{ENV}_CHECK_INTERVAL: Seconds between balance checks | |
| * - GRID_{ENV}_AUTO_TRANSFER: 'true' or 'false' - whether auto-transfer is enabled (default: 'true') | |
| * | |
| * You can also use non-prefixed variables (GRID_TRADING_PRIVATE_KEY, etc.) | |
| * as fallbacks or for a default environment. | |
| * | |
| * Global optional variables (apply to all environments): | |
| * - RUN_ONCE: If 'true', check once and exit; otherwise run continuously | |
| * | |
| * Usage: | |
| * node maintain-balance.js <instrument-name> [environment] | |
| * node maintain-balance.js chat-fast dev | |
| * node maintain-balance.js chat-prime demo | |
| * RUN_ONCE=true node maintain-balance.js chat-fast dev | |
| * ENV=prod node maintain-balance.js chat-fast | |
| * | |
| * Instrument name must be either "chat-fast" or "chat-prime" | |
| */ | |
| import axios from 'axios'; | |
| import nacl from 'tweetnacl'; | |
| import util from 'tweetnacl-util'; | |
| import crypto from 'crypto'; | |
| import dotenv from 'dotenv'; | |
| dotenv.config(); | |
| /** | |
| * Gets the instrument name from command line args. | |
| * @returns {string} Instrument name ('chat-fast' or 'chat-prime') | |
| */ | |
| function getInstrumentName() { | |
| const instrumentName = process.argv[2]; | |
| if (!instrumentName) { | |
| console.error('Error: Instrument name is required'); | |
| console.error('Usage: node maintain-balance.js <instrument-name> [environment]'); | |
| console.error(' Instrument name must be either "chat-fast" or "chat-prime"'); | |
| process.exit(1); | |
| } | |
| const normalized = instrumentName.toLowerCase(); | |
| if (normalized !== 'chat-fast' && normalized !== 'chat-prime') { | |
| console.error(`Error: Invalid instrument name "${instrumentName}"`); | |
| console.error(' Instrument name must be either "chat-fast" or "chat-prime"'); | |
| process.exit(1); | |
| } | |
| return normalized; | |
| } | |
| /** | |
| * Gets the environment name from command line args or environment variable. | |
| * @returns {string} Environment name (default: 'default') | |
| */ | |
| function getEnvironmentName() { | |
| // Check command line argument (second arg, after instrument name) | |
| const argEnv = process.argv[3]; | |
| if (argEnv && !argEnv.startsWith('-')) { | |
| return argEnv.toLowerCase(); | |
| } | |
| // Then check environment variable | |
| return (process.env.ENV || process.env.GRID_ENV || 'default').toLowerCase(); | |
| } | |
| /** | |
| * Gets an environment variable with environment-specific prefix, falling back to non-prefixed. | |
| * @param {string} envName - Environment name | |
| * @param {string} varName - Variable name (with TRADING_ prefix, e.g., 'TRADING_PRIVATE_KEY') | |
| * @param {string} [defaultValue] - Default value if not found | |
| * @returns {string|undefined} Environment variable value | |
| */ | |
| function getEnvVar(envName, varName, defaultValue = undefined) { | |
| const envPrefix = envName === 'default' ? '' : `GRID_${envName.toUpperCase()}_`; | |
| const prefixedName = envPrefix ? `${envPrefix}${varName}` : `GRID_${varName}`; | |
| const fallbackName = `GRID_${varName}`; | |
| return process.env[prefixedName] || process.env[fallbackName] || defaultValue; | |
| } | |
| /** | |
| * Gets a non-trading environment variable (for settings like MARKET_ID, MIN_BALANCE, etc.) | |
| * @param {string} envName - Environment name | |
| * @param {string} varName - Variable name (without prefix) | |
| * @param {string} [defaultValue] - Default value if not found | |
| * @returns {string|undefined} Environment variable value | |
| */ | |
| function getNonTradingEnvVar(envName, varName, defaultValue = undefined) { | |
| const envPrefix = envName === 'default' ? '' : `GRID_${envName.toUpperCase()}_`; | |
| const prefixedName = envPrefix ? `${envPrefix}${varName}` : `GRID_${varName}`; | |
| const fallbackName = `GRID_${varName}`; | |
| return process.env[prefixedName] || process.env[fallbackName] || defaultValue; | |
| } | |
| /** | |
| * Gets an instrument-specific environment variable (for MARKET_ID and INSTRUMENT_ID). | |
| * Format: GRID_{ENV}_{INSTRUMENT}_VAR_NAME (e.g., GRID_DEV_CHAT_FAST_INSTRUMENT_ID) | |
| * @param {string} envName - Environment name | |
| * @param {string} instrumentName - Instrument name (e.g., 'chat-fast', 'chat-prime') | |
| * @param {string} varName - Variable name (e.g., 'INSTRUMENT_ID', 'MARKET_ID') | |
| * @param {string} [defaultValue] - Default value if not found | |
| * @returns {string|undefined} Environment variable value | |
| */ | |
| function getInstrumentEnvVar(envName, instrumentName, varName, defaultValue = undefined) { | |
| // Convert instrument name to uppercase with underscores (chat-fast -> CHAT_FAST) | |
| const instrumentUpper = instrumentName.toUpperCase().replace(/-/g, '_'); | |
| // Try environment-specific instrument variable first | |
| const envPrefix = envName === 'default' ? '' : `GRID_${envName.toUpperCase()}_`; | |
| const prefixedName = envPrefix ? `${envPrefix}${instrumentUpper}_${varName}` : `GRID_${instrumentUpper}_${varName}`; | |
| // Fallback to non-prefixed instrument variable | |
| const fallbackName = `GRID_${instrumentUpper}_${varName}`; | |
| // Final fallback to old format (for backward compatibility) | |
| const oldFormatName = envPrefix ? `${envPrefix}${varName}` : `GRID_${varName}`; | |
| return process.env[prefixedName] || process.env[fallbackName] || process.env[oldFormatName] || defaultValue; | |
| } | |
| /** | |
| * Loads configuration for the specified environment and instrument. | |
| * @param {string} envName - Environment name | |
| * @param {string} instrumentName - Instrument name (e.g., 'chat-fast', 'chat-prime') | |
| * @returns {Object} Configuration object | |
| */ | |
| function loadConfig(envName, instrumentName) { | |
| const autoTransferStr = getNonTradingEnvVar(envName, 'AUTO_TRANSFER', 'true'); | |
| const config = { | |
| envName, | |
| instrumentName, | |
| baseUrl: getEnvVar(envName, 'TRADING_BASE_URL'), | |
| privateKey: getEnvVar(envName, 'TRADING_PRIVATE_KEY'), | |
| publicKey: getEnvVar(envName, 'TRADING_PUBLIC_KEY'), | |
| fingerprint: getEnvVar(envName, 'TRADING_FINGERPRINT'), | |
| marketId: getInstrumentEnvVar(envName, instrumentName, 'MARKET_ID'), | |
| instrumentId: getInstrumentEnvVar(envName, instrumentName, 'INSTRUMENT_ID'), | |
| minBalance: parseFloat(getNonTradingEnvVar(envName, 'MIN_BALANCE', '1')), | |
| buyQuantity: parseInt(getNonTradingEnvVar(envName, 'BUY_QUANTITY', '5'), 10), | |
| checkInterval: parseInt(getNonTradingEnvVar(envName, 'CHECK_INTERVAL', '60'), 10) * 1000, | |
| autoTransfer: autoTransferStr.toLowerCase() === 'true', | |
| runOnce: process.env.RUN_ONCE === 'true' | |
| }; | |
| return config; | |
| } | |
| /** | |
| * Signature-based authentication handler for Grid Trading API. | |
| * | |
| * Implements Ed25519 signature authentication using DER-encoded keys. | |
| * The signature is computed over: timestamp + HTTP method + path + request body | |
| */ | |
| class SignatureAuth { | |
| /** | |
| * Creates a new SignatureAuth instance. | |
| * | |
| * @param {string} privateKeyBase64 - Base64-encoded PKCS#8 Ed25519 private key | |
| * @param {string} publicKeyBase64 - Base64-encoded SPKI Ed25519 public key | |
| * @param {string} [fingerprint] - Optional fingerprint identifier | |
| */ | |
| constructor(privateKeyBase64, publicKeyBase64, fingerprint) { | |
| // Parse DER-encoded keys (PKCS#8 for private, SubjectPublicKeyInfo for public) | |
| const privateKeyDer = Buffer.from(privateKeyBase64, 'base64'); | |
| const publicKeyDer = Buffer.from(publicKeyBase64, 'base64'); | |
| // Extract raw Ed25519 keys from DER structure | |
| // For PKCS#8 Ed25519: private key (seed) is the last 32 bytes of the OCTET STRING | |
| // For SPKI Ed25519: public key is the last 32 bytes of the BIT STRING | |
| const rawPrivateKey = privateKeyDer.slice(-32); | |
| const rawPublicKey = publicKeyDer.slice(-32); | |
| // tweetnacl.sign.detached requires a 64-byte key (seed + public key) | |
| // Construct the 64-byte private key: [seed (32 bytes)][public key (32 bytes)] | |
| this.privateKey = new Uint8Array(64); | |
| this.privateKey.set(rawPrivateKey, 0); | |
| this.privateKey.set(rawPublicKey, 32); | |
| // Generate fingerprint from public key if not provided | |
| const publicKeyBuffer = Buffer.from(publicKeyBase64, 'base64'); | |
| const publicKeyHash = crypto.createHash('sha256').update(publicKeyBuffer).digest('base64'); | |
| this.fingerprint = fingerprint || publicKeyHash.replace(/=+$/, ''); | |
| } | |
| /** | |
| * Generates authentication headers for an API request. | |
| * | |
| * @param {string} method - HTTP method (e.g., 'POST', 'GET') | |
| * @param {string} path - API endpoint path | |
| * @param {string} [body=''] - Request body as string | |
| * @returns {Object} Headers object with signature, timestamp, and fingerprint | |
| */ | |
| getHeaders(method, path, body = '') { | |
| const timestamp = Math.floor(Date.now() / 1000).toString(); | |
| const message = `${timestamp}${method.toUpperCase()}${path}${body}`; | |
| const messageBytes = util.decodeUTF8(message); | |
| const signatureBytes = nacl.sign.detached(messageBytes, this.privateKey); | |
| return { | |
| 'x-thegrid-signature': util.encodeBase64(signatureBytes), | |
| 'x-thegrid-timestamp': timestamp, | |
| 'x-thegrid-fingerprint': this.fingerprint | |
| }; | |
| } | |
| } | |
| /** | |
| * Gets the environment prefix for error messages. | |
| * @param {string} envName - Environment name | |
| * @returns {string} Environment prefix string | |
| */ | |
| function getEnvPrefix(envName) { | |
| return envName === 'default' ? '' : `GRID_${envName.toUpperCase()}_`; | |
| } | |
| /** | |
| * Validates that required configuration is present. | |
| * @param {Object} config - Configuration object | |
| */ | |
| function validateConfig(config) { | |
| const envPrefix = getEnvPrefix(config.envName); | |
| if (!config.privateKey) { | |
| console.error(`Missing required configuration: ${envPrefix}TRADING_PRIVATE_KEY or GRID_TRADING_PRIVATE_KEY`); | |
| process.exit(1); | |
| } | |
| if (!config.publicKey) { | |
| console.error(`Missing required configuration: ${envPrefix}TRADING_PUBLIC_KEY or GRID_TRADING_PUBLIC_KEY`); | |
| process.exit(1); | |
| } | |
| if (!config.instrumentId) { | |
| const instrumentUpper = config.instrumentName.toUpperCase().replace(/-/g, '_'); | |
| console.error(`Missing required configuration: ${envPrefix}${instrumentUpper}_INSTRUMENT_ID or GRID_${instrumentUpper}_INSTRUMENT_ID`); | |
| console.error(` Example: GRID_${config.envName.toUpperCase()}_${instrumentUpper}_INSTRUMENT_ID`); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * Gets all instruments and returns a map of instrument_id -> instrument details. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @returns {Promise<Map<string, Object>>} Map of instrument_id to instrument details | |
| */ | |
| async function getAllInstrumentsMap(auth, baseUrl) { | |
| const path = '/api/v1/trading/instruments'; | |
| try { | |
| const response = await axios.get(`${baseUrl}${path}`, { | |
| headers: auth.getHeaders('GET', path) | |
| }); | |
| const instruments = response.data.data || []; | |
| const instrumentMap = new Map(); | |
| for (const instrument of instruments) { | |
| if (instrument.instrument_id) { | |
| instrumentMap.set(instrument.instrument_id, instrument); | |
| } | |
| } | |
| return instrumentMap; | |
| } catch (error) { | |
| if (error.response) { | |
| throw new Error( | |
| `Failed to get instruments: ${error.response.status} - ${JSON.stringify(error.response.data)}` | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Gets instrument details by instrument ID. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @param {string} instrumentId - Instrument ID to look up | |
| * @returns {Promise<Object|null>} Instrument data object or null if not found | |
| */ | |
| async function getInstrumentDetails(auth, baseUrl, instrumentId) { | |
| const instrumentMap = await getAllInstrumentsMap(auth, baseUrl); | |
| return instrumentMap.get(instrumentId) || null; | |
| } | |
| /** | |
| * Gets the current consumption balance for all instruments. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @returns {Promise<Array>} Array of consumption account objects | |
| */ | |
| async function getConsumptionBalance(auth, baseUrl) { | |
| const path = '/api/v1/trading/consumption-accounts'; | |
| try { | |
| const response = await axios.get(`${baseUrl}${path}?order_by=created_at`, { | |
| headers: auth.getHeaders('GET', path) | |
| }); | |
| return response.data.data || []; | |
| } catch (error) { | |
| if (error.response) { | |
| throw new Error( | |
| `Failed to get consumption balance: ${error.response.status} - ${JSON.stringify(error.response.data)}` | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Places a market buy order for units. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @param {string} marketId - Market ID | |
| * @param {number} quantity - Number of units to buy | |
| * @returns {Promise<Object>} Order response data | |
| */ | |
| async function placeBuyOrder(auth, baseUrl, marketId, quantity) { | |
| const orderData = { | |
| market_id: marketId, | |
| side: 'buy', | |
| type: 'market', | |
| quantity: quantity, | |
| time_in_force: 'gtc', | |
| client_order_id: `maintain-balance-${Date.now()}` | |
| }; | |
| const path = '/api/v1/trading/orders'; | |
| const body = JSON.stringify(orderData); | |
| try { | |
| const response = await axios.post(`${baseUrl}${path}`, body, { | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...auth.getHeaders('POST', path, body) | |
| } | |
| }); | |
| return response.data.data; | |
| } catch (error) { | |
| if (error.response) { | |
| throw new Error( | |
| `Failed to place order: ${error.response.status} - ${JSON.stringify(error.response.data)}` | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Gets the status of an order with retry logic for transient 404s. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @param {string} orderId - Order ID | |
| * @param {number} maxRetries - Maximum number of retries for 404s (default: 3) | |
| * @param {number} retryDelayMs - Initial delay between retries in ms (default: 500) | |
| * @returns {Promise<Object>} Order data with status | |
| */ | |
| async function getOrderStatus(auth, baseUrl, orderId, maxRetries = 3, retryDelayMs = 500) { | |
| const path = `/api/v1/trading/orders/${orderId}`; | |
| for (let attempt = 0; attempt <= maxRetries; attempt++) { | |
| try { | |
| const response = await axios.get(`${baseUrl}${path}`, { | |
| headers: auth.getHeaders('GET', path) | |
| }); | |
| return response.data.data; | |
| } catch (error) { | |
| // If it's a 404 and we have retries left, wait and retry | |
| if (error.response?.status === 404 && attempt < maxRetries) { | |
| const delay = retryDelayMs * Math.pow(2, attempt); // Exponential backoff | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| continue; | |
| } | |
| // For other errors or if we're out of retries, throw | |
| if (error.response) { | |
| throw new Error( | |
| `Failed to get order status: ${error.response.status} - ${JSON.stringify(error.response.data)}` | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| } | |
| /** | |
| * Waits for an order to be filled, polling with retries. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @param {string} orderId - Order ID | |
| * @param {number} maxWaitSeconds - Maximum seconds to wait (default: 15) | |
| * @param {number} pollIntervalSeconds - Seconds between polls (default: 2) | |
| * @param {number} initialDelayMs - Initial delay before first status check in ms (default: 1000) | |
| * @returns {Promise<Object>} Order data when filled | |
| */ | |
| async function waitForOrderFill(auth, baseUrl, orderId, maxWaitSeconds = 15, pollIntervalSeconds = 2, initialDelayMs = 1000) { | |
| // Wait a bit for the order to be available in the API after placement | |
| await new Promise(resolve => setTimeout(resolve, initialDelayMs)); | |
| const deadline = Date.now() + (maxWaitSeconds * 1000); | |
| const pollInterval = pollIntervalSeconds * 1000; | |
| let lastStatus = null; | |
| let pollCount = 0; | |
| while (Date.now() < deadline) { | |
| try { | |
| const order = await getOrderStatus(auth, baseUrl, orderId); | |
| const status = order.status?.toLowerCase(); | |
| lastStatus = order.status; | |
| // Check if order is filled (status should be "filled") | |
| if (status === 'filled') { | |
| return order; | |
| } | |
| // Check for terminal states that indicate failure | |
| if (status === 'cancelled' || status === 'canceled' || status === 'rejected') { | |
| throw new Error(`Order ${orderId} was ${order.status}`); | |
| } | |
| // Log current status periodically (every 5 polls to avoid spam) | |
| pollCount++; | |
| if (pollCount % 5 === 0) { | |
| console.log(` Order status: ${order.status || 'unknown'}...`); | |
| } | |
| } catch (error) { | |
| // If we get a 404, the order might not be available yet - keep trying | |
| if (error.message.includes('404')) { | |
| if (pollCount % 3 === 0) { // Log every 3rd failed attempt | |
| console.log(` Order not yet available, retrying...`); | |
| } | |
| } else { | |
| // For other errors, rethrow | |
| throw error; | |
| } | |
| } | |
| // Wait before next poll | |
| await new Promise(resolve => setTimeout(resolve, pollInterval)); | |
| } | |
| // Try to get final status before timing out | |
| try { | |
| const finalOrder = await getOrderStatus(auth, baseUrl, orderId); | |
| throw new Error(`Timeout waiting for order ${orderId} to fill (waited ${maxWaitSeconds}s, current status: ${finalOrder.status || 'unknown'})`); | |
| } catch (error) { | |
| // If we can't get final status, use last known status | |
| if (error.message.includes('Timeout')) { | |
| throw error; // Re-throw if it's already our timeout error | |
| } | |
| throw new Error(`Timeout waiting for order ${orderId} to fill (waited ${maxWaitSeconds}s, last known status: ${lastStatus || 'unknown'})`); | |
| } | |
| } | |
| /** | |
| * Transfers units from trading account to consumption account. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {string} baseUrl - Base URL for the API | |
| * @param {string} instrumentId - Instrument ID | |
| * @param {number} quantity - Number of units to transfer | |
| * @returns {Promise<Object>} Transfer response data | |
| */ | |
| async function transferToConsumption(auth, baseUrl, instrumentId, quantity) { | |
| const transferData = { | |
| instrument_id: instrumentId, | |
| quantity: quantity | |
| }; | |
| const path = '/api/v1/transfers/trading-to-consumption'; | |
| const body = JSON.stringify(transferData); | |
| try { | |
| const response = await axios.post(`${baseUrl}${path}`, body, { | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...auth.getHeaders('POST', path, body) | |
| } | |
| }); | |
| return response.data.data; | |
| } catch (error) { | |
| if (error.response) { | |
| throw new Error( | |
| `Failed to transfer to consumption: ${error.response.status} - ${JSON.stringify(error.response.data)}` | |
| ); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Formats instrument display with friendly name and ID. | |
| * | |
| * @param {string} instrumentId - Instrument ID | |
| * @param {Object|null} instrumentDetails - Instrument details object (optional) | |
| * @returns {string} Formatted string like "Prime Inference Token (instrument_abc123)" | |
| */ | |
| function formatInstrumentDisplay(instrumentId, instrumentDetails) { | |
| if (instrumentDetails) { | |
| const friendlyName = instrumentDetails.name || instrumentDetails.symbol || instrumentId; | |
| return `${friendlyName} (${instrumentId})`; | |
| } | |
| return instrumentId; | |
| } | |
| /** | |
| * Checks balance and replenishes if needed. | |
| * | |
| * @param {SignatureAuth} auth - Authentication instance | |
| * @param {Object} config - Configuration object | |
| * @returns {Promise<boolean>} True if order was placed, false otherwise | |
| */ | |
| async function checkAndReplenish(auth, config) { | |
| try { | |
| const accounts = await getConsumptionBalance(auth, config.baseUrl); | |
| // Fetch all instruments to get friendly names for display | |
| let instrumentMap = new Map(); | |
| try { | |
| instrumentMap = await getAllInstrumentsMap(auth, config.baseUrl); | |
| } catch (error) { | |
| // If we can't fetch instruments, continue without friendly names | |
| console.warn(` Warning: Could not fetch instrument details: ${error.message}`); | |
| } | |
| // Display all account balances | |
| const accountDetails = []; | |
| let monitoredBalance = null; | |
| for (const account of accounts) { | |
| const available = parseFloat(account.available_balance || 0); | |
| const isMonitored = account.instrument_id === config.instrumentId; | |
| accountDetails.push({ | |
| account_id: account.account_id, | |
| instrument_id: account.instrument_id, | |
| available: available, | |
| isMonitored: isMonitored | |
| }); | |
| // Track the balance for the monitored instrument | |
| if (isMonitored) { | |
| monitoredBalance = available; | |
| } | |
| } | |
| const timestamp = new Date().toISOString(); | |
| console.log(`[${timestamp}] Consumption Account Balances:`); | |
| if (accountDetails.length > 0) { | |
| accountDetails.forEach(acc => { | |
| const marker = acc.isMonitored ? ' ⭐ (monitored)' : ''; | |
| const instrumentDetails = instrumentMap.get(acc.instrument_id); | |
| const instrumentDisplay = formatInstrumentDisplay(acc.instrument_id, instrumentDetails); | |
| console.log(` ${instrumentDisplay}: ${acc.available.toFixed(4)} available${marker}`); | |
| }); | |
| } else { | |
| console.log(' No consumption accounts found'); | |
| } | |
| // Check if monitored instrument exists | |
| if (monitoredBalance === null) { | |
| const instrumentDisplay = formatInstrumentDisplay(config.instrumentId, config.instrumentDetails); | |
| console.log(`\n ⚠ Warning: Instrument '${instrumentDisplay}' not found in consumption accounts`); | |
| console.log(` Available instruments: ${accountDetails.map(a => a.instrument_id).join(', ') || 'none'}\n`); | |
| return false; | |
| } | |
| // Check balance for the monitored instrument only | |
| const instrumentDisplay = formatInstrumentDisplay(config.instrumentId, config.instrumentDetails); | |
| console.log(`\n Monitoring: ${instrumentDisplay}`); | |
| console.log(` Current consumption balance: ${monitoredBalance.toFixed(4)} units (minimum: ${config.minBalance})`); | |
| if (monitoredBalance >= config.minBalance) { | |
| console.log(` ✓ Balance is sufficient (>= ${config.minBalance}), no action needed\n`); | |
| return false; | |
| } | |
| const deficit = config.minBalance - monitoredBalance; | |
| const buyQuantity = Math.max(config.buyQuantity, Math.ceil(deficit)); | |
| console.log(` ⚠ Balance is low (< ${config.minBalance}), placing market buy order for ${buyQuantity} unit(s)...`); | |
| const orderResult = await placeBuyOrder( | |
| auth, | |
| config.baseUrl, | |
| config.marketId, | |
| buyQuantity | |
| ); | |
| // Use 'id' as primary field (per OrderSchema), with 'order_id' as fallback | |
| const orderId = orderResult.id || orderResult.order_id || orderResult.orderId; | |
| console.log(` ✓ Order placed: ${orderId} (market, ${buyQuantity} units)`); | |
| // If auto-transfer is disabled, wait for order to fill and then transfer manually | |
| if (!config.autoTransfer) { | |
| console.log(` Auto-transfer is disabled, waiting for order to fill...`); | |
| try { | |
| const filledOrder = await waitForOrderFill(auth, config.baseUrl, orderId, 15, 2); | |
| // Use 'id' as primary field, with 'order_id' as fallback | |
| const filledOrderId = filledOrder.id || filledOrder.order_id || orderId; | |
| console.log(` ✓ Order filled: ${filledOrderId} (status: ${filledOrder.status})`); | |
| console.log(` Transferring ${buyQuantity} unit(s) from trading to consumption account...`); | |
| const transferResult = await transferToConsumption( | |
| auth, | |
| config.baseUrl, | |
| config.instrumentId, | |
| buyQuantity | |
| ); | |
| console.log(` ✓ Transfer completed: ${transferResult.transfer_id || transferResult.id || 'success'}`); | |
| } catch (error) { | |
| console.error(` ✗ Error during transfer: ${error.message}`); | |
| // Don't throw - order was placed successfully, transfer can be retried later | |
| } | |
| } | |
| console.log(''); | |
| return true; | |
| } catch (error) { | |
| console.error(` ✗ Error: ${error.message}\n`); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Main function that runs the balance maintenance loop. | |
| */ | |
| async function main() { | |
| const instrumentName = getInstrumentName(); | |
| const envName = getEnvironmentName(); | |
| const config = loadConfig(envName, instrumentName); | |
| validateConfig(config); | |
| const auth = new SignatureAuth( | |
| config.privateKey, | |
| config.publicKey, | |
| config.fingerprint | |
| ); | |
| // Fetch instrument details to get friendly name | |
| let instrumentDetails = null; | |
| try { | |
| instrumentDetails = await getInstrumentDetails(auth, config.baseUrl, config.instrumentId); | |
| if (instrumentDetails) { | |
| config.instrumentDetails = instrumentDetails; | |
| } | |
| } catch (error) { | |
| console.warn(`Warning: Could not fetch instrument details: ${error.message}`); | |
| console.warn(' Continuing with instrument ID only...\n'); | |
| } | |
| console.log('Grid Consumption Balance Maintainer'); | |
| console.log('===================================='); | |
| console.log(`Environment: ${config.envName}`); | |
| console.log(`Instrument: ${config.instrumentName}`); | |
| const instrumentDisplay = formatInstrumentDisplay(config.instrumentId, config.instrumentDetails); | |
| console.log(`Monitoring: ${instrumentDisplay}`); | |
| console.log(`Minimum balance: ${config.minBalance} units`); | |
| console.log(`Buy quantity: ${config.buyQuantity} units`); | |
| console.log(`Order type: market`); | |
| console.log(`Auto-transfer: ${config.autoTransfer ? 'enabled' : 'disabled'}`); | |
| console.log(`Market ID: ${config.marketId}`); | |
| console.log(`Base URL: ${config.baseUrl}`); | |
| console.log(`Run mode: ${config.runOnce ? 'once' : `continuous (check every ${config.checkInterval / 1000}s)`}`); | |
| console.log(''); | |
| if (config.runOnce) { | |
| // Run once and exit | |
| try { | |
| await checkAndReplenish(auth, config); | |
| process.exit(0); | |
| } catch (error) { | |
| console.error('Fatal error:', error.message); | |
| process.exit(1); | |
| } | |
| } else { | |
| // Run continuously | |
| console.log('Starting continuous monitoring... Press Ctrl+C to stop.\n'); | |
| // Run immediately on start | |
| await checkAndReplenish(auth, config).catch(err => { | |
| console.error('Initial check failed:', err.message); | |
| }); | |
| // Then run on interval | |
| const intervalId = setInterval(async () => { | |
| try { | |
| await checkAndReplenish(auth, config); | |
| } catch (error) { | |
| console.error('Check failed:', error.message); | |
| // Continue running even if a check fails | |
| } | |
| }, config.checkInterval); | |
| // Handle graceful shutdown | |
| process.on('SIGINT', () => { | |
| console.log('\n\nShutting down gracefully...'); | |
| clearInterval(intervalId); | |
| process.exit(0); | |
| }); | |
| process.on('SIGTERM', () => { | |
| console.log('\n\nShutting down gracefully...'); | |
| clearInterval(intervalId); | |
| process.exit(0); | |
| }); | |
| } | |
| } | |
| // Export for use as a module | |
| export { SignatureAuth, getAllInstrumentsMap, getInstrumentDetails, getConsumptionBalance, placeBuyOrder, getOrderStatus, waitForOrderFill, transferToConsumption, checkAndReplenish }; | |
| // Execute when run directly | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment