From 8d0cddbd7409955cdd693f8cf10023135f888fb0 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 11 Feb 2026 04:44:05 +0300 Subject: [PATCH 1/6] feat: add GET /auth/finance/energy-balance endpoint Add energy balance API endpoint that aggregates power consumption, pool transactions, BTC prices, and production costs into a unified time-series response with period-based aggregation (daily/monthly/yearly). Includes shared infrastructure: period.utils, constants for RPC methods, worker types, aggregation fields, and utility functions (getStartOfDay, safeDiv, runParallel). --- tests/unit/handlers/finance.handlers.test.js | 241 ++++++++++++++++ tests/unit/lib/period.utils.test.js | 147 ++++++++++ tests/unit/routes/finance.routes.test.js | 57 ++++ workers/lib/constants.js | 56 +++- workers/lib/period.utils.js | 125 +++++++++ .../lib/server/handlers/finance.handlers.js | 261 ++++++++++++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/finance.routes.js | 35 +++ workers/lib/server/schemas/finance.schemas.js | 18 ++ workers/lib/utils.js | 21 +- 10 files changed, 961 insertions(+), 4 deletions(-) create mode 100644 tests/unit/handlers/finance.handlers.test.js create mode 100644 tests/unit/lib/period.utils.test.js create mode 100644 tests/unit/routes/finance.routes.test.js create mode 100644 workers/lib/period.utils.js create mode 100644 workers/lib/server/handlers/finance.handlers.js create mode 100644 workers/lib/server/routes/finance.routes.js create mode 100644 workers/lib/server/schemas/finance.schemas.js diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js new file mode 100644 index 0000000..2b84492 --- /dev/null +++ b/tests/unit/handlers/finance.handlers.test.js @@ -0,0 +1,241 @@ +'use strict' + +const test = require('brittle') +const { + getEnergyBalance, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processCostsData, + calculateSummary +} = require('../../../workers/lib/server/handlers/finance.handlers') + +test('getEnergyBalance - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { 1700006400000: { site_power_w: 5000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return { data: [{ transactions: [{ ts: 1700006400000, changed_balance: 50000, amount: 50000 }] }] } + } + if (payload.query && payload.query.key === 'prices') { + return { data: [{ prices: [{ ts: 1700006400000, price: 40000 }] }] } + } + if (payload.query && payload.query.key === 'current_price') { + return { data: { USD: 40000 } } + } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getEnergyBalance - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { end: 1700100000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - missing end throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEnergyBalance - empty ork results', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => ({}) + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.is(result.log.length, 0, 'log should be empty with no data') + t.pass() +}) + +test('processConsumptionData - processes valid data', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000 } } }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processConsumptionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processTransactionData - processes valid data', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processTransactionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processPriceData - processes valid data', (t) => { + const results = [ + [{ prices: [{ ts: 1700006400000, price: 40000 }] }] + ] + + const daily = processPriceData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('extractCurrentPrice - extracts numeric price', (t) => { + const results = [{ data: 42000 }] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract numeric price') + t.pass() +}) + +test('extractCurrentPrice - extracts object price', (t) => { + const results = [{ data: { USD: 42000 } }] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract USD from object') + t.pass() +}) + +test('extractCurrentPrice - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const price = extractCurrentPrice(results) + t.is(price, 0, 'should return 0 for error results') + t.pass() +}) + +test('processCostsData - processes valid costs array', (t) => { + const costs = [ + { key: '2023-11', value: { energyCostPerMWh: 50, operationalCostPerMWh: 10 } } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerMWh, 50, 'should have energy cost') + t.is(result['2023-11'].operationalCostPerMWh, 10, 'should have operational cost') + t.pass() +}) + +test('processCostsData - handles non-array input', (t) => { + const result = processCostsData(null) + t.ok(typeof result === 'object', 'should return object') + t.is(Object.keys(result).length, 0, 'should be empty') + t.pass() +}) + +test('calculateSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 }, + { revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 } + ] + + const summary = calculateSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue') + t.is(summary.totalCostUSD, 8000, 'should sum costs') + t.is(summary.totalProfitUSD, 24000, 'should sum profit') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') + t.pass() +}) + +test('calculateSummary - handles empty log', (t) => { + const summary = calculateSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.avgCostPerMWh, null, 'should be null') + t.pass() +}) diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js new file mode 100644 index 0000000..4581499 --- /dev/null +++ b/tests/unit/lib/period.utils.test.js @@ -0,0 +1,147 @@ +'use strict' + +const test = require('brittle') +const { + getStartOfDay, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} = require('../../../workers/lib/period.utils') + +test('getStartOfDay - returns start of day timestamp', (t) => { + const ts = 1700050000000 + const result = getStartOfDay(ts) + t.ok(result <= ts, 'should be less than or equal to input') + t.is(result % 86400000, 0, 'should be divisible by 86400000') + t.pass() +}) + +test('getStartOfDay - already at start of day', (t) => { + const ts = 1700006400000 + const result = getStartOfDay(ts) + t.is(result, ts, 'should return same timestamp if already start of day') + t.pass() +}) + +test('aggregateByPeriod - returns log unchanged for daily period', (t) => { + const log = [ + { ts: 1700006400000, value: 10 }, + { ts: 1700092800000, value: 20 } + ] + const result = aggregateByPeriod(log, 'daily') + t.is(result.length, 2, 'should return same length') + t.alike(result, log, 'should return same entries') + t.pass() +}) + +test('aggregateByPeriod - aggregates monthly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].month, 'should have month field') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - aggregates yearly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'yearly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - handles empty log', (t) => { + const result = aggregateByPeriod([], 'monthly') + t.is(result.length, 0, 'should return empty array') + t.pass() +}) + +test('aggregateByPeriod - handles invalid timestamps', (t) => { + const log = [ + { ts: 'invalid', value: 10 }, + { ts: 1700006400000, value: 20 } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should skip invalid entries') + t.pass() +}) + +test('getPeriodKey - daily returns start of day', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'daily') + t.is(result % 86400000, 0, 'should be start of day') + t.pass() +}) + +test('getPeriodKey - monthly returns start of month', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'monthly') + const date = new Date(result) + t.is(date.getDate(), 1, 'should be first day of month') + t.pass() +}) + +test('getPeriodKey - yearly returns start of year', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'yearly') + const date = new Date(result) + t.is(date.getMonth(), 0, 'should be January') + t.is(date.getDate(), 1, 'should be first day') + t.pass() +}) + +test('isTimestampInPeriod - daily exact match', (t) => { + const ts = 1700006400000 + t.ok(isTimestampInPeriod(ts, ts, 'daily'), 'should match exact timestamp') + t.ok(!isTimestampInPeriod(ts + 86400000, ts, 'daily'), 'should not match different day') + t.pass() +}) + +test('isTimestampInPeriod - monthly range', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const midMonth = new Date(2023, 10, 15).getTime() + const nextMonth = new Date(2023, 11, 1).getTime() + + t.ok(isTimestampInPeriod(midMonth, monthStart, 'monthly'), 'mid-month should be in period') + t.ok(!isTimestampInPeriod(nextMonth, monthStart, 'monthly'), 'next month should not be in period') + t.pass() +}) + +test('getFilteredPeriodData - daily returns direct lookup', (t) => { + const data = { 1700006400000: { value: 42 } } + const result = getFilteredPeriodData(data, 1700006400000, 'daily', () => null) + t.alike(result, { value: 42 }, 'should return data for timestamp') + t.pass() +}) + +test('getFilteredPeriodData - daily returns null for missing', (t) => { + const data = {} + const result = getFilteredPeriodData(data, 1700006400000, 'daily', () => null) + t.is(result, null, 'should return null for missing data') + t.pass() +}) + +test('getFilteredPeriodData - monthly filters with callback', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const day1 = new Date(2023, 10, 5).getTime() + const day2 = new Date(2023, 10, 15).getTime() + const data = { + [day1]: { value: 10 }, + [day2]: { value: 20 } + } + + const result = getFilteredPeriodData(data, monthStart, 'monthly', (entries) => { + return entries.reduce((sum, [, val]) => sum + val.value, 0) + }) + + t.is(result, 30, 'should sum values in period') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js new file mode 100644 index 0000000..f87c47f --- /dev/null +++ b/tests/unit/routes/finance.routes.test.js @@ -0,0 +1,57 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/finance.routes.js' + +test('finance routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'finance') + t.pass() +}) + +test('finance routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/finance/energy-balance'), 'should have energy-balance route') + + t.pass() +}) + +test('finance routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.is(route.method, 'GET', `route ${route.url} should be GET`) + }) + + t.pass() +}) + +test('finance routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routesWithSchemas = routes.filter(route => route.schema) + routesWithSchemas.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + if (route.schema.querystring) { + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + } + }) + + t.pass() +}) + +test('finance routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'finance') + t.pass() +}) + +test('finance routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'finance') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..e0c27dc 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,10 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + // Finance endpoints + FINANCE_ENERGY_BALANCE: '/auth/finance/energy-balance' } const HTTP_METHODS = { @@ -183,6 +186,48 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const RPC_METHODS = { + TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', + GET_WRK_EXT_DATA: 'getWrkExtData', + LIST_THINGS: 'listThings', + TAIL_LOG: 'tailLog' +} + +const WORKER_TYPES = { + MINER: 'miner', + POWERMETER: 'powermeter', + MINERPOOL: 'minerpool', + MEMPOOL: 'mempool' +} + +const AGGR_FIELDS = { + HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', + SITE_POWER: 'site_power_w' +} + +const PERIOD_TYPES = { + DAILY: 'daily', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + YEARLY: 'yearly' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' +} + +const NON_METRIC_KEYS = [ + 'ts', + 'region', + 'year', + 'monthName', + 'month', + 'period' +] + +const BTC_SATS = 100000000 + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 @@ -202,5 +247,12 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + USER_SETTINGS_TYPE, + RPC_METHODS, + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + NON_METRIC_KEYS, + BTC_SATS } diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js new file mode 100644 index 0000000..5233ade --- /dev/null +++ b/workers/lib/period.utils.js @@ -0,0 +1,125 @@ +'use strict' + +const { PERIOD_TYPES, NON_METRIC_KEYS } = require('./constants') + +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + +const PERIOD_CALCULATORS = { + daily: (timestamp) => getStartOfDay(timestamp), + monthly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), date.getMonth(), 1).getTime() + }, + yearly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), 0, 1).getTime() + } +} + +const aggregateByPeriod = (log, period, nonMetricKeys = []) => { + if (period === PERIOD_TYPES.DAILY) { + return log + } + + const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys]) + + const grouped = log.reduce((acc, entry) => { + let date + try { + date = new Date(Number(entry.ts)) + if (isNaN(date.getTime())) { + return acc + } + } catch (error) { + return acc + } + + let groupKey + if (period === PERIOD_TYPES.MONTHLY) { + groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } else if (period === PERIOD_TYPES.YEARLY) { + groupKey = `${date.getFullYear()}` + } else { + groupKey = `${entry.ts}` + } + + if (!acc[groupKey]) { + acc[groupKey] = [] + } + acc[groupKey].push(entry) + return acc + }, {}) + + const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => { + const aggregated = entries.reduce((acc, entry) => { + Object.entries(entry).forEach(([key, val]) => { + if (allNonMetricKeys.has(key)) { + if (!acc[key] || acc[key] === null || acc[key] === undefined) { + acc[key] = val + } + } else { + const numVal = Number(val) || 0 + acc[key] = (acc[key] || 0) + numVal + } + }) + return acc + }, {}) + + if (period === PERIOD_TYPES.MONTHLY) { + const [year, month] = groupKey.split('-').map(Number) + const newDate = new Date(year, month - 1, 1) + aggregated.ts = newDate.getTime() + aggregated.month = month + aggregated.year = year + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + const year = parseInt(groupKey) + const newDate = new Date(year, 0, 1) + aggregated.ts = newDate.getTime() + aggregated.year = year + } + + return aggregated + }) + + return aggregatedResults.sort((a, b) => Number(a.ts) - Number(b.ts)) +} + +const getPeriodKey = (timestamp, period) => { + const calculator = PERIOD_CALCULATORS[period] || PERIOD_CALCULATORS.daily + return calculator(timestamp) +} + +const isTimestampInPeriod = (timestamp, periodTs, period) => { + if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs + + const periodEnd = new Date(periodTs) + if (period === PERIOD_TYPES.MONTHLY) { + periodEnd.setMonth(periodEnd.getMonth() + 1) + } else if (period === PERIOD_TYPES.YEARLY) { + periodEnd.setFullYear(periodEnd.getFullYear() + 1) + } + + return timestamp >= periodTs && timestamp < periodEnd.getTime() +} + +const getFilteredPeriodData = (sourceData, periodTs, period, filterFn) => { + if (period === PERIOD_TYPES.DAILY) { + return sourceData[periodTs] || null + } + + const entriesInPeriod = Object.entries(sourceData).filter(([tsStr]) => { + const timestamp = Number(tsStr) + return isTimestampInPeriod(timestamp, periodTs, period) + }) + + return filterFn(entriesInPeriod, sourceData) +} + +module.exports = { + getStartOfDay, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js new file mode 100644 index 0000000..cf117af --- /dev/null +++ b/workers/lib/server/handlers/finance.handlers.js @@ -0,0 +1,261 @@ +'use strict' + +const { + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + RPC_METHODS, + BTC_SATS +} = require('../../constants') +const { + requestRpcEachLimit, + getStartOfDay, + safeDiv, + runParallel +} = require('../../utils') +const { aggregateByPeriod } = require('../../period.utils') + +async function getEnergyBalance (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.DAILY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + const startYearMonth = `${new Date(start).getFullYear()}-${String(new Date(start).getMonth() + 1).padStart(2, '0')}` + const endYearMonth = `${new Date(end).getFullYear()}-${String(new Date(end).getMonth() + 1).padStart(2, '0')}` + + const [consumptionResults, transactionResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'prices', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => ctx.globalDataLib.getGlobalData({ + type: 'productionCosts', + range: { gte: startYearMonth, lte: endYearMonth } + }).then(r => cb(null, r)).catch(cb) + ]) + + const dailyConsumption = processConsumptionData(consumptionResults) + const dailyTransactions = processTransactionData(transactionResults) + const dailyPrices = processPriceData(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const costsByMonth = processCostsData(productionCosts) + + const allDays = new Set([ + ...Object.keys(dailyConsumption), + ...Object.keys(dailyTransactions) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const consumption = dailyConsumption[dayTs] || {} + const transactions = dailyTransactions[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + + const powerW = consumption.powerW || 0 + const powerMWh = (powerW * 24) / 1000000 + const revenueBTC = transactions.revenueBTC || 0 + const revenueUSD = revenueBTC * btcPrice + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostPerMWh = costs.energyCostPerMWh || 0 + const operationalCostPerMWh = costs.operationalCostPerMWh || 0 + + const energyCostUSD = powerMWh * energyCostPerMWh + const totalCostUSD = powerMWh * (energyCostPerMWh + operationalCostPerMWh) + + log.push({ + ts, + powerW, + consumptionMWh: powerMWh, + revenueBTC, + revenueUSD, + btcPrice, + energyCostUSD, + totalCostUSD, + energyRevenuePerMWh: safeDiv(revenueUSD, powerMWh), + allInCostPerMWh: safeDiv(totalCostUSD, powerMWh), + profitUSD: revenueUSD - totalCostUSD + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateSummary(aggregated) + + return { log: aggregated, summary } +} + +function processConsumptionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.items || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!daily[ts]) daily[ts] = { powerW: 0 } + daily[ts].powerW += (item[AGGR_FIELDS.SITE_POWER] || item.site_power_w || 0) + } + } else if (typeof items === 'object') { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!daily[ts]) daily[ts] = { powerW: 0 } + const power = typeof val === 'object' ? (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) : (Number(val) || 0) + daily[ts].powerW += power + } + } + } + } + return daily +} + +function processTransactionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const ts = getStartOfDay(t.ts || t.timestamp || t.time) + if (!ts) continue + if (!daily[ts]) daily[ts] = { revenueBTC: 0 } + const amount = t.changed_balance || t.amount || t.value || 0 + daily[ts].revenueBTC += Math.abs(amount) / BTC_SATS + } + } + } + return daily +} + +function processPriceData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.prices || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp || item.time) + if (ts && item.price) { + daily[ts] = item.price + } + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (ts) { + daily[ts] = typeof val === 'object' ? (val.USD || val.price || 0) : Number(val) || 0 + } + } + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res[0] : res + if (!data) continue + const price = data.data || data.result || data + if (typeof price === 'number') return price + if (typeof price === 'object') return price.USD || price.price || price.current_price || 0 + } + return 0 +} + +function processCostsData (costs) { + const byMonth = {} + if (!Array.isArray(costs)) return byMonth + for (const entry of costs) { + if (!entry || !entry.key) continue + const key = entry.key + const data = entry.value || entry.data || entry + byMonth[key] = { + energyCostPerMWh: data.energyCostPerMWh || data.energy_cost_per_mwh || 0, + operationalCostPerMWh: data.operationalCostPerMWh || data.operational_cost_per_mwh || 0 + } + } + return byMonth +} + +function calculateSummary (log) { + if (!log.length) { + return { totalRevenueBTC: 0, totalRevenueUSD: 0, totalCostUSD: 0, totalProfitUSD: 0, avgCostPerMWh: null, avgRevenuePerMWh: null, totalConsumptionMWh: 0 } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.costUSD += entry.totalCostUSD || 0 + acc.profitUSD += entry.profitUSD || 0 + acc.consumptionMWh += entry.consumptionMWh || 0 + return acc + }, { revenueBTC: 0, revenueUSD: 0, costUSD: 0, profitUSD: 0, consumptionMWh: 0 }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalCostUSD: totals.costUSD, + totalProfitUSD: totals.profitUSD, + avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh), + avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + totalConsumptionMWh: totals.consumptionMWh + } +} + +module.exports = { + getEnergyBalance, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processCostsData, + calculateSummary +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..eb67811 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,7 @@ const globalRoutes = require('./routes/global.routes') const thingsRoutes = require('./routes/things.routes') const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') +const financeRoutes = require('./routes/finance.routes') /** * Collect all routes into a flat array for server injection. @@ -22,7 +23,8 @@ function routes (ctx) { ...thingsRoutes(ctx), ...usersRoutes(ctx), ...settingsRoutes(ctx), - ...wsRoutes(ctx) + ...wsRoutes(ctx), + ...financeRoutes(ctx) ] } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js new file mode 100644 index 0000000..5b6f583 --- /dev/null +++ b/workers/lib/server/routes/finance.routes.js @@ -0,0 +1,35 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getEnergyBalance +} = require('../handlers/finance.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/finance.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_ENERGY_BALANCE, + schema: { + querystring: schemas.query.energyBalance + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/energy-balance', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_ENERGY_BALANCE, + getEnergyBalance + ) + } + ] +} diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js new file mode 100644 index 0000000..b972076 --- /dev/null +++ b/workers/lib/server/schemas/finance.schemas.js @@ -0,0 +1,18 @@ +'use strict' + +const schemas = { + query: { + energyBalance: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..7d53473 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -128,6 +128,22 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + +const safeDiv = (num, denom) => { + if (!denom || !num) return null + return num / denom +} + +const runParallel = (tasks) => { + return new Promise((resolve, reject) => { + async.parallel(tasks, (err, results) => { + if (err) return reject(err) + resolve(results) + }) + }) +} + module.exports = { dateNowSec, extractIps, @@ -137,5 +153,8 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + getStartOfDay, + safeDiv, + runParallel } From 480e98a20eac9ac7e666a86ac843c51239c82242 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 11 Feb 2026 13:08:23 +0300 Subject: [PATCH 2/6] tiny improvements --- tests/unit/lib/period.utils.test.js | 29 +++++- workers/lib/constants.js | 2 +- workers/lib/period.utils.js | 95 ++++++++++++++----- .../lib/server/handlers/finance.handlers.js | 7 +- workers/lib/utils.js | 24 ++--- 5 files changed, 115 insertions(+), 42 deletions(-) diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js index 4581499..46ebaf4 100644 --- a/tests/unit/lib/period.utils.test.js +++ b/tests/unit/lib/period.utils.test.js @@ -3,6 +3,8 @@ const test = require('brittle') const { getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, aggregateByPeriod, getPeriodKey, isTimestampInPeriod, @@ -122,10 +124,10 @@ test('getFilteredPeriodData - daily returns direct lookup', (t) => { t.pass() }) -test('getFilteredPeriodData - daily returns null for missing', (t) => { +test('getFilteredPeriodData - daily returns empty object for missing with default filterFn', (t) => { const data = {} - const result = getFilteredPeriodData(data, 1700006400000, 'daily', () => null) - t.is(result, null, 'should return null for missing data') + const result = getFilteredPeriodData(data, 1700006400000, 'daily') + t.alike(result, {}, 'should return empty object for missing data with default filterFn') t.pass() }) @@ -145,3 +147,24 @@ test('getFilteredPeriodData - monthly filters with callback', (t) => { t.is(result, 30, 'should sum values in period') t.pass() }) + +test('convertMsToSeconds - converts milliseconds to seconds', (t) => { + t.is(convertMsToSeconds(1700006400000), 1700006400, 'should convert ms to seconds') + t.is(convertMsToSeconds(1700006400500), 1700006400, 'should floor fractional seconds') + t.pass() +}) + +test('getPeriodEndDate - monthly returns next month', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const result = getPeriodEndDate(monthStart, 'monthly') + t.is(result.getMonth(), 11, 'should be next month') + t.is(result.getFullYear(), 2023, 'should be same year') + t.pass() +}) + +test('getPeriodEndDate - yearly returns next year', (t) => { + const yearStart = new Date(2023, 0, 1).getTime() + const result = getPeriodEndDate(yearStart, 'yearly') + t.is(result.getFullYear(), 2024, 'should be next year') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index e0c27dc..33aa0ef 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -219,7 +219,7 @@ const MINERPOOL_EXT_DATA_KEYS = { const NON_METRIC_KEYS = [ 'ts', - 'region', + 'site', 'year', 'monthName', 'month', diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js index 5233ade..63a1144 100644 --- a/workers/lib/period.utils.js +++ b/workers/lib/period.utils.js @@ -4,6 +4,10 @@ const { PERIOD_TYPES, NON_METRIC_KEYS } = require('./constants') const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 +const convertMsToSeconds = (timestampMs) => { + return Math.floor(timestampMs / 1000) +} + const PERIOD_CALCULATORS = { daily: (timestamp) => getStartOfDay(timestamp), monthly: (timestamp) => { @@ -27,6 +31,7 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => { let date try { date = new Date(Number(entry.ts)) + if (isNaN(date.getTime())) { return acc } @@ -35,6 +40,7 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => { } let groupKey + if (period === PERIOD_TYPES.MONTHLY) { groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` } else if (period === PERIOD_TYPES.YEARLY) { @@ -65,24 +71,53 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => { return acc }, {}) - if (period === PERIOD_TYPES.MONTHLY) { - const [year, month] = groupKey.split('-').map(Number) - const newDate = new Date(year, month - 1, 1) - aggregated.ts = newDate.getTime() - aggregated.month = month - aggregated.year = year - aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) - } else if (period === PERIOD_TYPES.YEARLY) { - const year = parseInt(groupKey) - const newDate = new Date(year, 0, 1) - aggregated.ts = newDate.getTime() - aggregated.year = year + try { + if (period === PERIOD_TYPES.MONTHLY) { + const [year, month] = groupKey.split('-').map(Number) + + const newDate = new Date(year, month - 1, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for monthly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.month = month + aggregated.year = year + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + const year = parseInt(groupKey) + + const newDate = new Date(year, 0, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for yearly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.year = year + } + } catch (error) { + aggregated.ts = entries[0].ts + + try { + const fallbackDate = new Date(Number(entries[0].ts)) + if (!isNaN(fallbackDate.getTime())) { + if (period === PERIOD_TYPES.MONTHLY) { + aggregated.month = fallbackDate.getMonth() + 1 + aggregated.year = fallbackDate.getFullYear() + aggregated.monthName = fallbackDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + aggregated.year = fallbackDate.getFullYear() + } + } + } catch (fallbackError) { + console.warn('Could not extract date info from fallback timestamp', fallbackError) + } } return aggregated }) - return aggregatedResults.sort((a, b) => Number(a.ts) - Number(b.ts)) + return aggregatedResults.sort((a, b) => Number(b.ts) - Number(a.ts)) } const getPeriodKey = (timestamp, period) => { @@ -90,22 +125,36 @@ const getPeriodKey = (timestamp, period) => { return calculator(timestamp) } -const isTimestampInPeriod = (timestamp, periodTs, period) => { - if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs - +const getPeriodEndDate = (periodTs, period) => { const periodEnd = new Date(periodTs) - if (period === PERIOD_TYPES.MONTHLY) { - periodEnd.setMonth(periodEnd.getMonth() + 1) - } else if (period === PERIOD_TYPES.YEARLY) { - periodEnd.setFullYear(periodEnd.getFullYear() + 1) + + switch (period) { + case PERIOD_TYPES.MONTHLY: + periodEnd.setMonth(periodEnd.getMonth() + 1) + break + case PERIOD_TYPES.YEARLY: + periodEnd.setFullYear(periodEnd.getFullYear() + 1) + break } + return periodEnd +} + +const isTimestampInPeriod = (timestamp, periodTs, period) => { + if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs + + const periodEnd = getPeriodEndDate(periodTs, period) return timestamp >= periodTs && timestamp < periodEnd.getTime() } -const getFilteredPeriodData = (sourceData, periodTs, period, filterFn) => { +const getFilteredPeriodData = ( + sourceData, + periodTs, + period, + filterFn = (entries) => entries +) => { if (period === PERIOD_TYPES.DAILY) { - return sourceData[periodTs] || null + return sourceData[periodTs] || (typeof filterFn === 'function' ? {} : 0) } const entriesInPeriod = Object.entries(sourceData).filter(([tsStr]) => { @@ -118,6 +167,8 @@ const getFilteredPeriodData = (sourceData, periodTs, period, filterFn) => { module.exports = { getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, aggregateByPeriod, getPeriodKey, isTimestampInPeriod, diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index cf117af..7cf78d8 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -149,7 +149,7 @@ function processConsumptionData (results) { function processTransactionData (results) { const daily = {} for (const res of results) { - if (res.error || !res) continue + if (!res || res.error) continue const data = Array.isArray(res) ? res : (res.data || res.result || []) if (!Array.isArray(data)) continue for (const tx of data) { @@ -160,9 +160,8 @@ function processTransactionData (results) { if (!t) continue const ts = getStartOfDay(t.ts || t.timestamp || t.time) if (!ts) continue - if (!daily[ts]) daily[ts] = { revenueBTC: 0 } - const amount = t.changed_balance || t.amount || t.value || 0 - daily[ts].revenueBTC += Math.abs(amount) / BTC_SATS + const day = daily[ts] ??= { revenueBTC: 0 } + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) / BTC_SATS } } } diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 7d53473..6649b46 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -2,6 +2,7 @@ const async = require('async') const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT } = require('./constants') +const { getStartOfDay } = require('./period.utils') const dateNowSec = () => Math.floor(Date.now() / 1000) @@ -128,21 +129,20 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } -const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 - -const safeDiv = (num, denom) => { - if (!denom || !num) return null - return num / denom -} - -const runParallel = (tasks) => { - return new Promise((resolve, reject) => { +const runParallel = (tasks) => + new Promise((resolve, reject) => { async.parallel(tasks, (err, results) => { - if (err) return reject(err) - resolve(results) + if (err) reject(err) + else resolve(results) }) }) -} + +const safeDiv = (numerator, denominator) => + typeof numerator === 'number' && + typeof denominator === 'number' && + denominator !== 0 + ? numerator / denominator + : null module.exports = { dateNowSec, From 113f2d0d045f3a73e5910877046f8d7eb581f5bb Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 16:37:14 +0300 Subject: [PATCH 3/6] Fix costs bugs --- tests/unit/handlers/finance.handlers.test.js | 117 +++++++++++++----- .../lib/server/handlers/finance.handlers.js | 105 +++++++++------- workers/lib/server/routes/finance.routes.js | 3 +- workers/lib/server/schemas/finance.schemas.js | 1 + 4 files changed, 153 insertions(+), 73 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 2b84492..bb2aa47 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -12,6 +12,7 @@ const { } = require('../../../workers/lib/server/handlers/finance.handlers') test('getEnergyBalance - happy path', async (t) => { + const dayTs = 1700006400000 const mockCtx = { conf: { orks: [{ rpcPublicKey: 'key1' }] @@ -19,24 +20,28 @@ test('getEnergyBalance - happy path', async (t) => { net_r0: { jRequest: async (key, method, payload) => { if (method === 'tailLogCustomRangeAggr') { - return [{ data: { 1700006400000: { site_power_w: 5000 } } }] + return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000 } }], error: null }] } if (method === 'getWrkExtData') { if (payload.query && payload.query.key === 'transactions') { - return { data: [{ transactions: [{ ts: 1700006400000, changed_balance: 50000, amount: 50000 }] }] } + return [{ ts: dayTs, transactions: [{ ts: dayTs, changed_balance: 0.5 }] }] } - if (payload.query && payload.query.key === 'prices') { - return { data: [{ prices: [{ ts: 1700006400000, price: 40000 }] }] } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return [{ ts: dayTs, priceUSD: 40000 }] } if (payload.query && payload.query.key === 'current_price') { - return { data: { USD: 40000 } } + return [{ currentPrice: 40000 }] } } return {} } }, - globalDataLib: { - getGlobalData: async () => [] + globalDataBee: { + sub: () => ({ + sub: () => ({ + createReadStream: () => (async function * () {})() + }) + }) } } @@ -53,9 +58,9 @@ test('getEnergyBalance - happy path', async (t) => { test('getEnergyBalance - missing start throws', async (t) => { const mockCtx = { - conf: { orks: [] }, + conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataLib: { getGlobalData: async () => [] } + globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } } const mockReq = { query: { end: 1700100000000 } } @@ -71,9 +76,9 @@ test('getEnergyBalance - missing start throws', async (t) => { test('getEnergyBalance - missing end throws', async (t) => { const mockCtx = { - conf: { orks: [] }, + conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataLib: { getGlobalData: async () => [] } + globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } } const mockReq = { query: { start: 1700000000000 } } @@ -89,9 +94,9 @@ test('getEnergyBalance - missing end throws', async (t) => { test('getEnergyBalance - invalid range throws', async (t) => { const mockCtx = { - conf: { orks: [] }, + conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataLib: { getGlobalData: async () => [] } + globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } } const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } @@ -113,8 +118,12 @@ test('getEnergyBalance - empty ork results', async (t) => { net_r0: { jRequest: async () => ({}) }, - globalDataLib: { - getGlobalData: async () => [] + globalDataBee: { + sub: () => ({ + sub: () => ({ + createReadStream: () => (async function * () {})() + }) + }) } } @@ -129,7 +138,20 @@ test('getEnergyBalance - empty ork results', async (t) => { t.pass() }) -test('processConsumptionData - processes valid data', (t) => { +test('processConsumptionData - processes daily data from ORK', (t) => { + const results = [ + [{ type: 'powermeter', data: [{ ts: 1700006400000, val: { site_power_w: 5000 } }], error: null }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].powerW, 5000, 'should extract power from val') + t.pass() +}) + +test('processConsumptionData - processes object-keyed data', (t) => { const results = [ [{ data: { 1700006400000: { site_power_w: 5000 } } }] ] @@ -147,13 +169,29 @@ test('processConsumptionData - handles error results', (t) => { t.pass() }) -test('processTransactionData - processes valid data', (t) => { +test('processTransactionData - processes F2Pool data', (t) => { + const results = [ + [{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') + t.pass() +}) + +test('processTransactionData - processes Ocean data', (t) => { const results = [ - [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] + [{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }] ] const daily = processTransactionData(results) t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') t.pass() }) @@ -165,27 +203,34 @@ test('processTransactionData - handles error results', (t) => { t.pass() }) -test('processPriceData - processes valid data', (t) => { +test('processPriceData - processes mempool price data', (t) => { const results = [ - [{ prices: [{ ts: 1700006400000, price: 40000 }] }] + [{ ts: 1700006400000, priceUSD: 40000 }] ] const daily = processPriceData(results) t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 40000, 'should extract priceUSD') t.pass() }) -test('extractCurrentPrice - extracts numeric price', (t) => { - const results = [{ data: 42000 }] +test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => { + const results = [ + [{ currentPrice: 42000, blockHeight: 900000 }] + ] const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract numeric price') + t.is(price, 42000, 'should extract currentPrice') t.pass() }) -test('extractCurrentPrice - extracts object price', (t) => { - const results = [{ data: { USD: 42000 } }] +test('extractCurrentPrice - extracts priceUSD', (t) => { + const results = [ + [{ ts: 1700006400000, priceUSD: 42000 }] + ] const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract USD from object') + t.is(price, 42000, 'should extract priceUSD') t.pass() }) @@ -196,15 +241,27 @@ test('extractCurrentPrice - handles error results', (t) => { t.pass() }) -test('processCostsData - processes valid costs array', (t) => { +test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { + const costs = [ + { region: 'site1', year: 2023, month: 11, energyCostsUSD: 50000, operationalCostsUSD: 10000 } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostUSD, 50000, 'should have energy cost') + t.is(result['2023-11'].operationalCostUSD, 10000, 'should have operational cost') + t.pass() +}) + +test('processCostsData - processes app-node format (energyCost)', (t) => { const costs = [ - { key: '2023-11', value: { energyCostPerMWh: 50, operationalCostPerMWh: 10 } } + { site: 'site1', year: 2023, month: 11, energyCost: 50000, operationalCost: 10000 } ] const result = processCostsData(costs) t.ok(result['2023-11'], 'should have month key') - t.is(result['2023-11'].energyCostPerMWh, 50, 'should have energy cost') - t.is(result['2023-11'].operationalCostPerMWh, 10, 'should have operational cost') + t.is(result['2023-11'].energyCostUSD, 50000, 'should have energy cost') + t.is(result['2023-11'].operationalCostUSD, 10000, 'should have operational cost') t.pass() }) diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 7cf78d8..2ae79af 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -6,7 +6,8 @@ const { PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, RPC_METHODS, - BTC_SATS + BTC_SATS, + GLOBAL_DATA_TYPES } = require('../../constants') const { requestRpcEachLimit, @@ -31,8 +32,6 @@ async function getEnergyBalance (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const startYearMonth = `${new Date(start).getFullYear()}-${String(new Date(start).getMonth() + 1).padStart(2, '0')}` - const endYearMonth = `${new Date(end).getFullYear()}-${String(new Date(end).getMonth() + 1).padStart(2, '0')}` const [consumptionResults, transactionResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { @@ -52,7 +51,7 @@ async function getEnergyBalance (ctx, req) { (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, - query: { key: 'prices', start, end } + query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { @@ -60,10 +59,8 @@ async function getEnergyBalance (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => ctx.globalDataLib.getGlobalData({ - type: 'productionCosts', - range: { gte: startYearMonth, lte: endYearMonth } - }).then(r => cb(null, r)).catch(cb) + (cb) => getProductionCosts(ctx, req.query.site, start, end) + .then(r => cb(null, r)).catch(cb) ]) const dailyConsumption = processConsumptionData(consumptionResults) @@ -91,11 +88,9 @@ async function getEnergyBalance (ctx, req) { const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` const costs = costsByMonth[monthKey] || {} - const energyCostPerMWh = costs.energyCostPerMWh || 0 - const operationalCostPerMWh = costs.operationalCostPerMWh || 0 - - const energyCostUSD = powerMWh * energyCostPerMWh - const totalCostUSD = powerMWh * (energyCostPerMWh + operationalCostPerMWh) + const daysInMonth = new Date(new Date(ts).getFullYear(), new Date(ts).getMonth() + 1, 0).getDate() + const energyCostUSD = (costs.energyCostUSD || 0) / daysInMonth + const totalCostUSD = ((costs.energyCostUSD || 0) + (costs.operationalCostUSD || 0)) / daysInMonth log.push({ ts, @@ -125,17 +120,20 @@ function processConsumptionData (results) { const data = Array.isArray(res) ? res : (res.data || res.result || []) if (!Array.isArray(data)) continue for (const entry of data) { - if (!entry) continue + if (!entry || entry.error) continue const items = entry.data || entry.items || entry if (Array.isArray(items)) { for (const item of items) { const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue if (!daily[ts]) daily[ts] = { powerW: 0 } - daily[ts].powerW += (item[AGGR_FIELDS.SITE_POWER] || item.site_power_w || 0) + const val = item.val || item + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) } } else if (typeof items === 'object') { for (const [key, val] of Object.entries(items)) { const ts = getStartOfDay(Number(key)) + if (!ts) continue if (!daily[ts]) daily[ts] = { powerW: 0 } const power = typeof val === 'object' ? (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) : (Number(val) || 0) daily[ts].powerW += power @@ -146,6 +144,11 @@ function processConsumptionData (results) { return daily } +function normalizeTimestampMs (ts) { + if (!ts) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + function processTransactionData (results) { const daily = {} for (const res of results) { @@ -158,10 +161,15 @@ function processTransactionData (results) { if (!Array.isArray(txList)) continue for (const t of txList) { if (!t) continue - const ts = getStartOfDay(t.ts || t.timestamp || t.time) + const rawTs = t.ts || t.created_at || t.timestamp || t.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) if (!ts) continue const day = daily[ts] ??= { revenueBTC: 0 } - day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) / BTC_SATS + if (t.satoshis_net_earned) { + day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS + } else { + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) + } } } } @@ -176,21 +184,11 @@ function processPriceData (results) { if (!Array.isArray(data)) continue for (const entry of data) { if (!entry) continue - const items = entry.data || entry.prices || entry - if (Array.isArray(items)) { - for (const item of items) { - const ts = getStartOfDay(item.ts || item.timestamp || item.time) - if (ts && item.price) { - daily[ts] = item.price - } - } - } else if (typeof items === 'object' && !Array.isArray(items)) { - for (const [key, val] of Object.entries(items)) { - const ts = getStartOfDay(Number(key)) - if (ts) { - daily[ts] = typeof val === 'object' ? (val.USD || val.price || 0) : Number(val) || 0 - } - } + const rawTs = entry.ts || entry.timestamp || entry.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + const price = entry.priceUSD || entry.price + if (ts && price) { + daily[ts] = price } } } @@ -200,25 +198,47 @@ function processPriceData (results) { function extractCurrentPrice (results) { for (const res of results) { if (res.error || !res) continue - const data = Array.isArray(res) ? res[0] : res - if (!data) continue - const price = data.data || data.result || data - if (typeof price === 'number') return price - if (typeof price === 'object') return price.USD || price.price || price.current_price || 0 + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.currentPrice) return entry.currentPrice + if (entry.priceUSD) return entry.priceUSD + if (entry.price) return entry.price + } } return 0 } +async function getProductionCosts (ctx, site, start, end) { + if (!site) return [] + + const db = ctx.globalDataBee + .sub(GLOBAL_DATA_TYPES.PRODUCTION_COSTS) + .sub(site) + const stream = db.createReadStream() + const entries = [] + + for await (const entry of stream) { + entries.push(JSON.parse(entry.value.toString())) + } + + const startDate = new Date(start) + const endDate = new Date(end) + return entries.filter(entry => { + const entryDate = new Date(entry.year, entry.month - 1, 1) + return entryDate >= startDate && entryDate <= endDate + }) +} + function processCostsData (costs) { const byMonth = {} if (!Array.isArray(costs)) return byMonth for (const entry of costs) { - if (!entry || !entry.key) continue - const key = entry.key - const data = entry.value || entry.data || entry + if (!entry || !entry.year || !entry.month) continue + const key = `${entry.year}-${String(entry.month).padStart(2, '0')}` byMonth[key] = { - energyCostPerMWh: data.energyCostPerMWh || data.energy_cost_per_mwh || 0, - operationalCostPerMWh: data.operationalCostPerMWh || data.operational_cost_per_mwh || 0 + energyCostUSD: entry.energyCostsUSD || entry.energyCost || 0, + operationalCostUSD: entry.operationalCostsUSD || entry.operationalCost || 0 } } return byMonth @@ -251,6 +271,7 @@ function calculateSummary (log) { module.exports = { getEnergyBalance, + getProductionCosts, processConsumptionData, processTransactionData, processPriceData, diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 5b6f583..4efc740 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -25,7 +25,8 @@ module.exports = (ctx) => { 'finance/energy-balance', req.query.start, req.query.end, - req.query.period + req.query.period, + req.query.site ], ENDPOINTS.FINANCE_ENERGY_BALANCE, getEnergyBalance diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index b972076..3d2ff42 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -8,6 +8,7 @@ const schemas = { start: { type: 'integer' }, end: { type: 'integer' }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + site: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From a4bd214ac47eb223ec6edd917b29b9476ae7f268 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 18:08:17 +0300 Subject: [PATCH 4/6] feat: add curtailment, operational issues, and power utilization to energy-balance - Add ELECTRICITY worker type, ENERGY_AGGR/ACTIVE_ENERGY_IN/UTE_ENERGY aggr fields, and GLOBAL_CONFIG RPC method to constants - Add 3 new parallel RPC calls: electricity stats-history (active_energy_in, ute_energy) and getGlobalConfig (nominalPowerAvailability_MW) - Calculate curtailmentMWh, curtailmentRate, operationalIssuesRate, and powerUtilization per log entry - Add avgCurtailmentRate, avgOperationalIssuesRate, avgPowerUtilization to summary - Fix getProductionCosts to use ctx.globalDataLib instead of direct Hyperbee access - Fix processCostsData to return daily costs (energyCostPerDay, operationalCostPerDay) - Update tests to match new cost format and globalDataLib mock --- tests/unit/handlers/finance.handlers.test.js | 34 ++-- workers/lib/constants.js | 11 +- .../lib/server/handlers/finance.handlers.js | 166 +++++++++++++++--- 3 files changed, 171 insertions(+), 40 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index bb2aa47..bb905c4 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -7,6 +7,8 @@ const { processTransactionData, processPriceData, extractCurrentPrice, + processEnergyData, + extractNominalPower, processCostsData, calculateSummary } = require('../../../workers/lib/server/handlers/finance.handlers') @@ -32,16 +34,18 @@ test('getEnergyBalance - happy path', async (t) => { if (payload.query && payload.query.key === 'current_price') { return [{ currentPrice: 40000 }] } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } } return {} } }, - globalDataBee: { - sub: () => ({ - sub: () => ({ - createReadStream: () => (async function * () {})() - }) - }) + globalDataLib: { + getGlobalData: async () => [] } } @@ -60,7 +64,7 @@ test('getEnergyBalance - missing start throws', async (t) => { const mockCtx = { conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } + globalDataLib: { getGlobalData: async () => [] } } const mockReq = { query: { end: 1700100000000 } } @@ -78,7 +82,7 @@ test('getEnergyBalance - missing end throws', async (t) => { const mockCtx = { conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } + globalDataLib: { getGlobalData: async () => [] } } const mockReq = { query: { start: 1700000000000 } } @@ -96,7 +100,7 @@ test('getEnergyBalance - invalid range throws', async (t) => { const mockCtx = { conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, - globalDataBee: { sub: () => ({ sub: () => ({ createReadStream: () => (async function * () {})() }) }) } + globalDataLib: { getGlobalData: async () => [] } } const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } @@ -243,25 +247,25 @@ test('extractCurrentPrice - handles error results', (t) => { test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { const costs = [ - { region: 'site1', year: 2023, month: 11, energyCostsUSD: 50000, operationalCostsUSD: 10000 } + { region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 } ] const result = processCostsData(costs) t.ok(result['2023-11'], 'should have month key') - t.is(result['2023-11'].energyCostUSD, 50000, 'should have energy cost') - t.is(result['2023-11'].operationalCostUSD, 10000, 'should have operational cost') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') t.pass() }) test('processCostsData - processes app-node format (energyCost)', (t) => { const costs = [ - { site: 'site1', year: 2023, month: 11, energyCost: 50000, operationalCost: 10000 } + { site: 'site1', year: 2023, month: 11, energyCost: 30000, operationalCost: 6000 } ] const result = processCostsData(costs) t.ok(result['2023-11'], 'should have month key') - t.is(result['2023-11'].energyCostUSD, 50000, 'should have energy cost') - t.is(result['2023-11'].operationalCostUSD, 10000, 'should have operational cost') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 33aa0ef..622b8df 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -190,19 +190,24 @@ const RPC_METHODS = { TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', GET_WRK_EXT_DATA: 'getWrkExtData', LIST_THINGS: 'listThings', - TAIL_LOG: 'tailLog' + TAIL_LOG: 'tailLog', + GLOBAL_CONFIG: 'getGlobalConfig' } const WORKER_TYPES = { MINER: 'miner', POWERMETER: 'powermeter', MINERPOOL: 'minerpool', - MEMPOOL: 'mempool' + MEMPOOL: 'mempool', + ELECTRICITY: 'electricity' } const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', - SITE_POWER: 'site_power_w' + SITE_POWER: 'site_power_w', + ENERGY_AGGR: 'energy_aggr', + ACTIVE_ENERGY_IN: 'active_energy_in_aggr', + UTE_ENERGY: 'ute_energy_aggr' } const PERIOD_TYPES = { diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 2ae79af..a5abcc1 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -33,7 +33,16 @@ async function getEnergyBalance (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const [consumptionResults, transactionResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ + const [ + consumptionResults, + transactionResults, + priceResults, + currentPriceResults, + productionCosts, + activeEnergyInResults, + uteEnergyResults, + globalConfigResults + ] = await runParallel([ (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.POWERMETER, @@ -60,6 +69,19 @@ async function getEnergyBalance (ctx, req) { }).then(r => cb(null, r)).catch(cb), (cb) => getProductionCosts(ctx, req.query.site, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) .then(r => cb(null, r)).catch(cb) ]) @@ -68,6 +90,9 @@ async function getEnergyBalance (ctx, req) { const dailyPrices = processPriceData(priceResults) const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) + const dailyActiveEnergyIn = processEnergyData(activeEnergyInResults, AGGR_FIELDS.ACTIVE_ENERGY_IN) + const dailyUteEnergy = processEnergyData(uteEnergyResults, AGGR_FIELDS.UTE_ENERGY) + const nominalPowerMW = extractNominalPower(globalConfigResults) const allDays = new Set([ ...Object.keys(dailyConsumption), @@ -88,14 +113,33 @@ async function getEnergyBalance (ctx, req) { const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` const costs = costsByMonth[monthKey] || {} - const daysInMonth = new Date(new Date(ts).getFullYear(), new Date(ts).getMonth() + 1, 0).getDate() - const energyCostUSD = (costs.energyCostUSD || 0) / daysInMonth - const totalCostUSD = ((costs.energyCostUSD || 0) + (costs.operationalCostUSD || 0)) / daysInMonth + const energyCostUSD = costs.energyCostPerDay || 0 + const totalCostUSD = energyCostUSD + (costs.operationalCostPerDay || 0) + + const activeEnergyIn = dailyActiveEnergyIn[dayTs] || 0 + const uteEnergy = dailyUteEnergy[dayTs] || 0 + const consumptionMWh = powerMWh + + const curtailmentMWh = activeEnergyIn > 0 + ? activeEnergyIn - consumptionMWh + : null + const curtailmentRate = curtailmentMWh !== null + ? safeDiv(curtailmentMWh, consumptionMWh) + : null + + const operationalIssuesRate = uteEnergy > 0 + ? safeDiv(uteEnergy - consumptionMWh, uteEnergy) + : null + + const actualPowerMW = powerW / 1000000 + const powerUtilization = nominalPowerMW > 0 + ? safeDiv(actualPowerMW, nominalPowerMW) + : null log.push({ ts, powerW, - consumptionMWh: powerMWh, + consumptionMWh, revenueBTC, revenueUSD, btcPrice, @@ -103,7 +147,11 @@ async function getEnergyBalance (ctx, req) { totalCostUSD, energyRevenuePerMWh: safeDiv(revenueUSD, powerMWh), allInCostPerMWh: safeDiv(totalCostUSD, powerMWh), - profitUSD: revenueUSD - totalCostUSD + profitUSD: revenueUSD - totalCostUSD, + curtailmentMWh, + curtailmentRate, + operationalIssuesRate, + powerUtilization }) } @@ -209,22 +257,55 @@ function extractCurrentPrice (results) { return 0 } -async function getProductionCosts (ctx, site, start, end) { - if (!site) return [] - - const db = ctx.globalDataBee - .sub(GLOBAL_DATA_TYPES.PRODUCTION_COSTS) - .sub(site) - const stream = db.createReadStream() - const entries = [] +function processEnergyData (results, aggrField) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = Array.isArray(entry) ? entry : (entry.data || entry) + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + const energyAggr = item[AGGR_FIELDS.ENERGY_AGGR] + if (energyAggr && energyAggr[aggrField]) { + daily[ts] = (daily[ts] || 0) + Number(energyAggr[aggrField]) + } + } + } + } + } + return daily +} - for await (const entry of stream) { - entries.push(JSON.parse(entry.value.toString())) +function extractNominalPower (results) { + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.nominalPowerAvailability_MW) return entry.nominalPowerAvailability_MW + } } + return 0 +} + +async function getProductionCosts (ctx, site, start, end) { + if (!ctx.globalDataLib) return [] + const costs = await ctx.globalDataLib.getGlobalData({ + type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS + }) + if (!Array.isArray(costs)) return [] const startDate = new Date(start) const endDate = new Date(end) - return entries.filter(entry => { + return costs.filter(entry => { + if (!entry || !entry.year || !entry.month) return false + if (site && entry.site !== site) return false const entryDate = new Date(entry.year, entry.month - 1, 1) return entryDate >= startDate && entryDate <= endDate }) @@ -236,9 +317,10 @@ function processCostsData (costs) { for (const entry of costs) { if (!entry || !entry.year || !entry.month) continue const key = `${entry.year}-${String(entry.month).padStart(2, '0')}` + const daysInMonth = new Date(entry.year, entry.month, 0).getDate() byMonth[key] = { - energyCostUSD: entry.energyCostsUSD || entry.energyCost || 0, - operationalCostUSD: entry.operationalCostsUSD || entry.operationalCost || 0 + energyCostPerDay: (entry.energyCost || entry.energyCostsUSD || 0) / daysInMonth, + operationalCostPerDay: (entry.operationalCost || entry.operationalCostsUSD || 0) / daysInMonth } } return byMonth @@ -246,7 +328,18 @@ function processCostsData (costs) { function calculateSummary (log) { if (!log.length) { - return { totalRevenueBTC: 0, totalRevenueUSD: 0, totalCostUSD: 0, totalProfitUSD: 0, avgCostPerMWh: null, avgRevenuePerMWh: null, totalConsumptionMWh: 0 } + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalCostUSD: 0, + totalProfitUSD: 0, + avgCostPerMWh: null, + avgRevenuePerMWh: null, + totalConsumptionMWh: 0, + avgCurtailmentRate: null, + avgOperationalIssuesRate: null, + avgPowerUtilization: null + } } const totals = log.reduce((acc, entry) => { @@ -255,8 +348,32 @@ function calculateSummary (log) { acc.costUSD += entry.totalCostUSD || 0 acc.profitUSD += entry.profitUSD || 0 acc.consumptionMWh += entry.consumptionMWh || 0 + if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { + acc.curtailmentRateSum += entry.curtailmentRate + acc.curtailmentRateCount++ + } + if (entry.operationalIssuesRate !== null && entry.operationalIssuesRate !== undefined) { + acc.operationalIssuesRateSum += entry.operationalIssuesRate + acc.operationalIssuesRateCount++ + } + if (entry.powerUtilization !== null && entry.powerUtilization !== undefined) { + acc.powerUtilizationSum += entry.powerUtilization + acc.powerUtilizationCount++ + } return acc - }, { revenueBTC: 0, revenueUSD: 0, costUSD: 0, profitUSD: 0, consumptionMWh: 0 }) + }, { + revenueBTC: 0, + revenueUSD: 0, + costUSD: 0, + profitUSD: 0, + consumptionMWh: 0, + curtailmentRateSum: 0, + curtailmentRateCount: 0, + operationalIssuesRateSum: 0, + operationalIssuesRateCount: 0, + powerUtilizationSum: 0, + powerUtilizationCount: 0 + }) return { totalRevenueBTC: totals.revenueBTC, @@ -265,7 +382,10 @@ function calculateSummary (log) { totalProfitUSD: totals.profitUSD, avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh), avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), - totalConsumptionMWh: totals.consumptionMWh + totalConsumptionMWh: totals.consumptionMWh, + avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), + avgOperationalIssuesRate: safeDiv(totals.operationalIssuesRateSum, totals.operationalIssuesRateCount), + avgPowerUtilization: safeDiv(totals.powerUtilizationSum, totals.powerUtilizationCount) } } @@ -276,6 +396,8 @@ module.exports = { processTransactionData, processPriceData, extractCurrentPrice, + processEnergyData, + extractNominalPower, processCostsData, calculateSummary } From 1e5e29c4e55772705750d70e9587fab3dddb959d Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sun, 15 Feb 2026 16:10:09 +0300 Subject: [PATCH 5/6] Remove unncecessaey sute param and add integration tests --- tests/integration/api.security.test.js | 5 +++++ workers/lib/server/handlers/finance.handlers.js | 5 ++--- workers/lib/server/routes/finance.routes.js | 3 +-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 58f9728..07c6869 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -559,6 +559,11 @@ test('Api security', { timeout: 90000 }, async (main) => { }) }) + await main.test('Api: get finance/energy-balance', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_ENERGY_BALANCE}?start=1700000000000&end=1700100000000` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + await main.test('Token expiration: api should fail due to token expiration', async (t) => { const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_RACKS}?type=miner` worker.worker.auth_a0.conf.ttl = 5 diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index a5abcc1..88d1d41 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -68,7 +68,7 @@ async function getEnergyBalance (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => getProductionCosts(ctx, req.query.site, start, end) + (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { @@ -294,7 +294,7 @@ function extractNominalPower (results) { return 0 } -async function getProductionCosts (ctx, site, start, end) { +async function getProductionCosts (ctx, start, end) { if (!ctx.globalDataLib) return [] const costs = await ctx.globalDataLib.getGlobalData({ type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS @@ -305,7 +305,6 @@ async function getProductionCosts (ctx, site, start, end) { const endDate = new Date(end) return costs.filter(entry => { if (!entry || !entry.year || !entry.month) return false - if (site && entry.site !== site) return false const entryDate = new Date(entry.year, entry.month - 1, 1) return entryDate >= startDate && entryDate <= endDate }) diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 4efc740..5b6f583 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -25,8 +25,7 @@ module.exports = (ctx) => { 'finance/energy-balance', req.query.start, req.query.end, - req.query.period, - req.query.site + req.query.period ], ENDPOINTS.FINANCE_ENERGY_BALANCE, getEnergyBalance From cd7a6b777a74d25fa5daff7d569c14edb7ba3cf2 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sun, 15 Feb 2026 16:12:18 +0300 Subject: [PATCH 6/6] removes site --- workers/lib/server/schemas/finance.schemas.js | 1 - 1 file changed, 1 deletion(-) diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index 3d2ff42..b972076 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -8,7 +8,6 @@ const schemas = { start: { type: 'integer' }, end: { type: 'integer' }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, - site: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end']